Merge Records

Before merging duplicate records, names every value that will be discarded, so a merge doesn't silently throw data away.

StopsDuplicate merge discarded customer data

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/merge-records.json

The files land in your project. They're yours to edit.

Usage

merge-records-usage.tsx
import { MergeRecords, type MergeField, type MergeRecordItem } from "@/components/ui/merge-records"

<MergeRecords
  key={run}
  records={RECORDS}
  fields={FIELDS}
  formatDate={(d) => d.toLocaleDateString("en-GB", { day: "numeric", month: "short", timeZone: "UTC" })}
  onCancel={() => setRun((n) => n + 1)}
  onMerge={(result) =>
    new Promise<void>((resolve) =>
      setTimeout(() => {
        setDone(`Merged into ${result.survivorId}. ${result.discarded.length} values discarded.`)
        resolve()
      }, 600),
    )
  }
/>

What it catches

  • Values that the merge will discard, listed by name
  • Real conflicts vs case/spacing differences
  • Which record keeps its ID
  • Links (deals, tickets, integrations) that move to the survivor

Props

MergeRecords props
PropTypeDefault
records* (required)

The two or three duplicates. Order sets the A/B/C letters.

MergeRecordItem[]—
fields* (required)

Fields to compare, in display order.

MergeField[]—
value

Controlled picks. Defaults to the most recent non-empty value per field.

MergePicks—
defaultValue

Initial picks when uncontrolled.

MergePicks—
onValueChange

Called with the picks on every change.

(picks: MergePicks) => void—
defaultSurvivor

Record whose ID and links survive. Defaults to the one with the most links.

string—
onMerge* (required)

Runs with the result. Return a promise to show a pending state; a rejection is caught.

(result: { survivorId: string; removedIds: string[]; values: Record<string, string>; discarded: string[] }) => void | Promise<void>—
onCancel

Called when the user cancels.

() => void—
formatDate

Formats a record's updated date.

(date: Date) => string(d) => d.toLocaleDateString(undefined, { day: "numeric", …
conflictLabel

Label for a field whose values really differ.

string"Conflict"
agreeLabel

Label for the count of fields that agree.

(count: number) => string(n) => `${n} ${n === 1 ? "field agrees" : "fields agree"}`
survivorLabel

Label for the survivor choice.

string"Keep the ID and links of"
discardLabel

Label for the count of values discarded.

(count: number) => string(n) => `Discarded on merge (${n})`
nothingLostText

Shown when nothing will be discarded.

string"No values are lost."
irreversibleText

One honest line on reversibility.

string"Merging can't be undone."
mergeLabel

Merge button text from the count and survivor ID.

(count: number, survivorId: string) => string(n, id) => `Merge ${n} records into ${id}`
pendingLabel

Merge button text while merging.

string"Merging…"
cancelLabel

Cancel button text.

string"Cancel"

* required. Also accepts the native props of <form> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Per-field choices and the survivor are native radio inputs in labelled groups
  • Readback highlight animation is skipped under reduced motion
  • Icons are aria-hidden

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

This is a UX guard rail. Enforce on the server: Run the merge in one transaction on the server and keep an audit record of discarded values.

  • Defaults by last-updated time, which isn't always the right value
  • Moves and deletes are done by your onMerge
  • One value per field; it can't combine lists

What it stops

Duplicate merge discarded customer data

Also known as

merge duplicates · dedupe · crm · contacts · customer records · merge contacts · duplicate leads

Field type

Dependencies

Pairs well with