HighX
Client

Editing a live record

useDraft — typing into a record that is also arriving from the server.

A record that comes from a subscription and is being typed into needs more than useMutation. Every collaborative application needs the same more, so it lives in one hook.

This is the hook for a record — fields where two writers is a genuine either/or, and the loser deserves to be told. For a field where the answer is almost always "both" (prose, a tally, a shared board), reach for useCollaborative instead: it has no conflict to resolve because two people typing in one paragraph is not a collision.

One row often wants both. In the reference application a note's title and colour use useDraft while its body is collaborative.

const draft = useDraft<Note>({
  value: note,                          // the server's current version
  version: (note) => note.updatedAt,    // where the version lives
  fields: ["title", "body"],
  save: (changes, baseUpdatedAt) => save({ id: note.id, baseUpdatedAt, ...changes }),
});

<input
  value={draft.values.title ?? ""}
  onChange={(e) => draft.set({ title: e.target.value })}
/>

{draft.conflict !== null && (
  <p>
    {draft.conflict.editedBy} changed this while you were writing.
    <button onClick={draft.takeTheirs}>Take theirs</button>
    <button onClick={draft.keepMine}>Keep mine</button>
  </p>
)}

Give the component a key of the record's id. A draft belongs to one record, and switching records means a new draft.

What it handles

ConvergingA newer version is adopted outright when nothing local is at stake — including while the field is focused. Guarding on focus means a record stops updating the moment someone clicks into it
Never going backwardsBetween an acknowledgement and its patch, the draft knows a newer version than the record it is handed, and does not adopt the older one
PacingThrottled, not debounced, defaulting to 120 ms. A debounce waits for a pause, which is exactly when nobody is watching
CoalescingOne write in flight at a time; everything typed meanwhile merges into the next. Two writes cannot share a base version, and two fields typed together travel as one transaction
ConflictsA write refused with conflict is not swallowed. The typing survives and conflict holds their version until takeTheirs or keepMine answers it

The server has to cooperate

save takes the version the edit was based on and answers with the new one. On the server that is a compare against the stored version, refused with HighXError("conflict", …):

if (args.baseUpdatedAt !== existing["updatedAt"]) {
  throw new HighXError("conflict", `${existing["editedBy"]} changed this first`);
}

Optimistic concurrency in six lines — and the reason a client that was offline cannot replay its queued write over everything written while it was away. See mutations.

On this page