Collaborative text editing
Build text that several people edit at once, online or offline, with a text merge column.Having text fields that can be edited by multiple users at the same time is a common need. In Jazz, declare the column as a text merge column and keep writing it as a plain string.
Why a plain string column is not enough
A plain string column works well for replacing a value. When two people change it at the same time, one edit wins the whole field. The other edit stays in the row's history, but it disappears from the text.
Declare a text merge column
A text merge column (s.string().merge("text"), or "markdown" / "html") keeps storing a plain
string, and the server merges concurrent edits inside it. Concurrent edits to different words all
survive; where two people replaced the same words, the later edit wins that part.
schema.tsimport { schema as s } from "jazz-tools";import { migrations } from "./migrations";const schema = {notes: s.table({title: s.string(),body: s.string().merge("markdown").default(""),},{},),};type AppSchema = s.Schema<typeof schema>;export const app: s.App<AppSchema> = s.defineApp(schema, { migrations });
Pick the format that matches what the column holds: "text" for prose, "markdown" for Markdown
(what most rich text editors can import and export), "html" for HTML. The format decides which
pieces a merge keeps whole, such as Markdown markers or HTML tags; see
mergeable text columns.
A text merge column can be optional. Two people adding text to a null column at the same time
both keep their text, while clearing it to null against someone else's edit goes to whichever
write reaches the server later. If you don't need to tell "no text yet" from empty text, a required
column with .default("") is simpler; see
mergeable text columns.
Write and read it like any string
There is no extra library. Write the whole current value with db.update(...), and read it with a
normal query:
db.update(app.notes, noteId, { body: nextBody });
An editor should also say which value it edited. Between a merged value reaching the local store and your UI showing it (the next render), someone can still type into the old text; written as a plain value, that keystroke would undo the other person's edit. Written as an edit of the value the editor last showed, it is merged over the newer value instead:
db.update(app.notes, noteId, { body: nextBody }, { editedFrom: { body: shownBody } });
shownBody is the value the editor last displayed or wrote; after the write, it is nextBody. It
must be exactly that: not the value a subscription delivers, which can lag a keystroke or two
behind your own writes, and would then apply those keystrokes again.
For a plain <textarea> or <input>, textFieldSync from jazz-tools/text-field does this
bookkeeping. It writes each change as an edit of the value the field last showed or wrote, takes
a late echo of its own writes as nothing new, and applies merged values as a change, so the
cursor stays with the text it was in:
import { useEffect, useRef, useState } from "react";import { useDb } from "jazz-tools/react";import { textFieldSync, type TextFieldSync } from "jazz-tools/text-field";function NoteBody({ note }: { note: { id: string; body: string } }) {const db = useDb();const field = useRef<HTMLTextAreaElement>(null);const sync = useRef<TextFieldSync | null>(null);// The field's first text, given once: React writes a changed// `defaultValue` into a field nobody typed in yet, bypassing the sync.const [initial] = useState(note.body);useEffect(() => {const synced = textFieldSync(field.current!, {onChange: (body, { editedFrom }) =>db.update(app.notes, note.id, { body }, { editedFrom: { body: editedFrom } }),});sync.current = synced;return () => synced.destroy();}, [db, note.id]);useEffect(() => sync.current?.apply(note.body), [note.body]);// Uncontrolled: only the sync sets the field's text.return <textarea ref={field} defaultValue={initial} />;}
Render it with key={note.id}, so that it starts over for each note. Keep the field
uncontrolled, with a defaultValue that never changes: a controlled field resets its whole text
on each merged value, which moves the cursor and can drop a keystroke typed in between. Because
the sync sets the field's text, the browser's own undo (Ctrl+Z) does not reach past a merged
value. An editor that mounted before the row loaded must not name "": it would add the whole
text again.
editedFrom is not supported yet in branch views, on a column with its own transform, or (on a
backend) for a write as a user outside a transaction; those writes throw. upsert does not take
it.
Queries, filters and permissions see the text itself. Large values work too: a long note is stored and synced like any other large string.
Editing offline
Each edit is merged against the version its writer had seen, so a device that edits offline for a while merges its whole session when it reconnects, including its own chain of edits. Until the server's result arrives, the device shows its own text, then switches to the merged value.
Connecting an editor
A few habits keep an editor pleasant to use with a text merge column:
- Write every change, as an edit. Write the editor's value on each change, without
debouncing, with
editedFromset to the value the editor last showed or wrote. Until the server's merge of a write comes back, the client keeps showing that write, so every merged value that arrives already contains everything typed before it. An editor that holds edits back instead has text the incoming value doesn't know about, and applying that value deletes it. - Apply incoming values as a change, not a reset. When the merged value arrives, compute the difference from what the editor shows and apply only that, so the cursor and selection stay where they are. Replacing the editor's whole content moves the cursor to the start.
- Keep the serialization stable. If your editor converts between its own document model and Markdown or HTML, check that loading a value and saving it again without edits gives back the same string. Otherwise every open editor rewrites the text, and those rewrites compete with real edits.
For ProseMirror, jazz-tools/prosemirror does all three; the
rich text editor recipe also adds comments and live
cursors.
What to expect from a merge
- Edits to different parts of the text all survive, and an edit both people made applies once.
- Two insertions at the same spot both stay, the earlier one first. Two people typing into the same
word both keep their letters; typed at the very same spot without seeing each other's keystrokes,
the letters interleave (
catedited tocartand tocastmerges tocarst). - Where both people replaced the same words, the edit the server receives later wins that part. The other version stays in the row's history.
- The merge keeps Markdown and HTML markup intact but is not tree-aware: it does not check that the merged result is well formed.
See how text merges work for the full rules, including what happens when a very large value is rewritten heavily on both sides.