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
| Converging | A 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 backwards | Between an acknowledgement and its patch, the draft knows a newer version than the record it is handed, and does not adopt the older one |
| Pacing | Throttled, not debounced, defaulting to 120 ms. A debounce waits for a pause, which is exactly when nobody is watching |
| Coalescing | One 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 |
| Conflicts | A 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.