Skip to documentation content

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.

change-review-demo

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

ChangeReview
  • 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.

KeyAction
TabMove across the filter, bulk actions, and each row's decision buttons
SpaceToggle the filter or a focused decision button
EnterActivate a focused button

Installation

npx honestui@latest add change-review

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.

change-review-decisions

Audit history

Stored change records with a custom header in place of the default one.

change-review-audit

Nested groups and custom values

An order with grouped fields, a status badge, formatted money and dates, and unchanged fields shown for context.

change-review-nested

No changes

When every field is unchanged, the list is replaced by a short message.

change-review-empty

API reference

ChangeReview

Root provider and layout wrapper. Accepts native section props beyond those listed. Pass either before, after, and fields, or changes.

PropTypeDefaultDescription
beforeunknownNoneThe saved version of the object.
afterunknownNoneThe proposed or resulting version.
fieldsChangeReviewFieldNode[]NoneFields to compare, with labels, types, and groups.
changesChangeReviewChange[]NoneChange records to show instead of comparing snapshots.
changedOnlybooleanNoneControlled filter that hides unchanged fields.
defaultChangedOnlybooleantrueInitial filter state when uncontrolled.
onChangedOnlyChange(changedOnly) => voidNoneRuns when the filter switch changes.
decisionsChangeReviewDecisionsNoneControlled decisions keyed by change id.
defaultDecisionsChangeReviewDecisions{}Initial decisions when uncontrolled.
onDecisionsChange(decisions) => voidNoneReceives every decision change. Passing it shows the controls.
disabledbooleanfalseKeeps the decision controls visible but inactive.
renderValue(value, change) => ReactNodeNoneDisplay for values whose field has no render.
labelsPartial<ChangeReviewLabels>EnglishReplacement text and count wording.
maxListItemsnumber5List items shown before the rest collapse.

Fields

PropertyTypeApplies toDescription
keystringAll fieldsDot path into both objects. Also the change id used in decisions.
labelstringAll fieldsThe row's visible name.
type"value" | "boolean" | "list"All fields"boolean" shows Enabled and Disabled; "list" compares membership. Defaults to "value".
render(value) => ReactNodeValue fieldsDisplay for one side of the change.
isEqual(before, after) => booleanValue fieldsReplaces comparison by content.
itemKeystring | (item) => stringList fieldsIdentifies an item across both versions.
itemLabel(item) => ReactNodeList fieldsText shown for an item.
group and fieldsstring and ChangeReviewFieldNode[]GroupsA heading and the fields or groups under it.

Parts

PartRendersNotes
ChangeReviewHeaderdivSummary, filter, and bulk actions by default; pass children to replace them.
ChangeReviewSummaryp, status regionChange count, plus decision counts when decisions are collected.
ChangeReviewFilterdiv with SwitchRenders nothing when every field changed.
ChangeReviewBulkActionsdiv with two ButtonsRenders nothing without onDecisionsChange or when nothing changed.
ChangeReviewListdiv containing description listsRows, groups, and the empty message.

Functions

ExportSignatureDescription
getChanges(before, after, fields) => ChangeReviewChange[]The comparison the component runs, for use on a server or in tests.
getChangeStatus(change) => "added" | "removed" | "changed" | "unchanged"How a single change is classified.
applyAcceptedChanges(before, after, decisions) => Tbefore with each accepted field taken from after.
useChangeReviewController(componentName) => ChangeReviewControllerValueReads changes, counts, and decision actions inside a custom part.

Types

ExportShape
ChangeReviewChange{ id; label; groups?; status?; before?; after?; type?; render? } or { id; label; groups?; kind: "list"; added; removed; changed?; itemLabel? }
ChangeReviewDecisionsRecord<string, "accepted" | "rejected">
ChangeReviewFieldNodeA field, or { group: string; fields: ChangeReviewFieldNode[] }
ChangeReviewLabelsEvery string and count function the component writes