Change Review
Show what changed between two versions of an object, field by field, and collect a decision on each change when your application needs one.
Overview
ChangeReview shows the difference between two versions of a structured object, such as settings, a profile, permissions, an order, or configuration. Each field gets one row with its label, the old value, and the new value. A list field says how many items were added and removed, then names them. People read "Billing email: old address → new address" instead of a JSON diff.
You can give it two snapshots and a list of fields, and it works out what changed. Or you can give it change records your application already stores. The same component then serves a confirmation step before saving, an entry in an audit history, and an approval screen.
ChangeReview does not save, apply, or revert anything. When you pass a decision handler it shows accept and reject controls and reports each choice. Your application decides what an accepted change does.
Anatomy
- HeaderOne row above the list
- SummaryCount of changes, and of decisions when they are collected
- FilterSwitch that hides unchanged fields; absent when every field changed
- Bulk actionsReject all and Accept all; absent unless decisions are collected
- ListBordered list of rows, nested under group headings
- GroupHeading with its own rows and nested groups
- RowLabel, change, and decision controls
- LabelThe field's name
- ChangeOld value, arrow, new value; or a list summary with its items
- DecisionReject and Accept toggle buttons on changed rows
<ChangeReview /> with no children renders the header and the list. Pass children to choose the parts yourself, for example to replace the header with a sentence about who made the change.
Installing change-review brings Button and Switch along as registry dependencies.
Behavior
Two ways to supply changes
Pass before, after, and fields when you hold two versions of the object. Each field names a key, which is a dot path such as billing.email, and a label. ChangeReview reads both values and compares them.
Pass changes when you hold records instead of snapshots, which is common in audit logs. Each record carries an id, a label, and either before and after or, for a list, added and removed.
What counts as a change
Values are compared by content, so two equal dates, arrays, or objects are unchanged even when they are different instances. An empty value is null, undefined, or an empty string. A field that goes from empty to a value reads "Not set → value", and the reverse reads "value → Not set".
Give a field isEqual when your data needs a different rule, for example to ignore surrounding spaces.
Lists
A field with type: "list" compares membership. itemKey names the property that identifies an item across both versions, and itemLabel returns the text shown for it. The row says how many items were added and removed, then names each one.
An item that appears in both versions but whose contents differ is listed as changed. Its details are not shown, so use separate fields when the change inside an item matters, such as a member's role.
Long lists show the first five items and a "Show more" button. Change the limit with maxListItems.
Groups
Wrap fields in { group, fields } to put them under a heading. Groups nest to any depth, and a group whose fields are all hidden by the filter is hidden with them.
Changed fields only
Unchanged fields are hidden by default. The header shows a "Changed fields only" switch whenever at least one field did not change, so people can bring the full object back for context. Use changedOnly and onChangedOnlyChange to control it, or defaultChangedOnly={false} to start with everything visible.
Decisions
Pass onDecisionsChange and every changed row gains Reject and Accept buttons, with Reject all and Accept all in the header. Without the handler the controls are not rendered, because nothing would receive the choice.
Decisions are a record keyed by change id, holding "accepted" or "rejected". A change with no entry is undecided. Pressing the chosen button again clears it.
applyAcceptedChanges(before, after, decisions) returns before with each accepted field taken from after. Rejected and undecided fields keep their old value. Call it when the reviewer confirms, then save the result yourself.
Accessibility
Rows are a description list, so each label is announced with its change. Screen readers hear "Was old value, now new value": the words are in the markup, and the arrow is decorative. Added and removed list items are announced as "Added" and "Removed" before the item.
Nothing depends on color. An old value is struck through and followed by an arrow, list items carry plus and minus icons, and a chosen decision gains an icon as well as a fill.
Reject and Accept are toggle buttons that report their pressed state. Each is named for its field, such as "Accept change to Billing email", so the buttons make sense out of context. The summary is a polite status region, so the decision counts are announced as they change.
Give the root an aria-label or aria-labelledby that says what is being reviewed. Each group is labelled by its heading.
Installation
Usage
Describe the fields once, then pass both versions of the object:
import {
ChangeReview,
type ChangeReviewFieldNode,
} from "@/components/ui/change-review/change-review"
const fields: ChangeReviewFieldNode[] = [
{ key: "billingEmail", label: "Billing email" },
{ key: "notifications", label: "Notifications", type: "boolean" },
{
key: "members",
label: "Team access",
type: "list",
itemKey: "id",
itemLabel: (member: Member) => member.name,
},
]
<ChangeReview aria-label="Changes to workspace settings" before={saved} after={draft} fields={fields} />Define fields outside the component, or memoize it, so the comparison does not run again on every render.
To collect decisions, own them in state and apply the accepted ones when the reviewer confirms:
const [decisions, setDecisions] = React.useState<ChangeReviewDecisions>({})
<ChangeReview
aria-label="Requested profile changes"
before={current}
after={requested}
fields={fields}
decisions={decisions}
onDecisionsChange={setDecisions}
/>
<Button onClick={() => save(applyAcceptedChanges(current, requested, decisions))}>
Apply accepted changes
</Button>To show stored records, pass changes and compose the parts you need:
<ChangeReview aria-label="Changes made by Ana Ruiz" changes={entry.changes}>
<ChangeReviewList />
</ChangeReview>Custom values
A field's render receives the raw value from one side and returns what to show. Use it for money, dates, status badges, or anything stored in a form people should not read directly. renderValue on the root is the fallback for fields without their own.
{ key: "payment.total", label: "Order total", render: (value) => pounds.format(Number(value) / 100) }Other languages
Every word the component writes comes from labels. Pass the ones you want to replace. Counts are functions, so each language can apply its own plural rules.
<ChangeReview
labels={{
notSet: "Nicht festgelegt",
summary: ({ changed }) => (changed === 1 ? "1 Änderung" : `${changed} Änderungen`),
}}
{...review}
/>Don't do this
Showing secrets
// Bad. The old and new API keys are printed in full.
{ key: "apiKey", label: "API key" }// Good. The row says that the key changed without revealing either value.
{ key: "apiKey", label: "API key", render: () => "Hidden" }A review screen is often shared, screenshotted, or shown to an approver with fewer permissions than the author. Mask secret values in render.
Treating a click as a save
// Bad. Each click writes to the server, and a misclick is already live.
onDecisionsChange={(decisions) => api.save(applyAcceptedChanges(before, after, decisions))}Keep decisions in state and save once, from a button that says what will happen. Decisions are easy to change until then.
Examples
Approving individual changes
The parent owns the decisions. The line below the review shows the profile that would be saved.
Audit history
Stored change records with a custom header in place of the default one.
Nested groups and custom values
An order with grouped fields, a status badge, formatted money and dates, and unchanged fields shown for context.
No changes
When every field is unchanged, the list is replaced by a short message.
API reference
ChangeReview
Root provider and layout wrapper. Accepts native section props beyond those listed. Pass either before, after, and fields, or changes.