Skip to content
Get started— browse docs
On this page

Step 4: Render it

useDocument gives you data AND actions — read the notes, and write one back.

data gets you the notes. actions is how you change them. Why actions, and not useState? Because the notes don't belong to this component — they belong to the shared document. An actions write goes into that document and merges with everyone else's edits; data is the merged result coming back. There's no save button and no local copy to reconcile, which is exactly what makes Step 6 possible.

This step is all in the src/tool.tsx half of the pane — everything below the // src/tool.tsx marker; the codec at the top stays as it is. (The split rule for the merged import block, as ever: defineStateCodec and field belong to src/codec.ts, every other import to src/tool.tsx — and module-level helpers the view uses, like newId, live in src/tool.tsx too.) Replace the whole Board function with one that renders every note as a textarea, and adds a button that creates one. The props type is written out by hand because the scaffold's tsconfig is strict — an untyped { data, actions } destructuring is an implicit-any error, not a shortcut:

src/tool.tsx
const newId = () => crypto.randomUUID()

function Board({
  data,
  actions,
}: {
  data: { notes: Readonly<Record<string, Note>> }
  actions: {
    map(name: "notes"): { set(key: string, value: Note): void }
  }
}) {
  const notes = Object.entries(data.notes)
  const addNote = () => actions.map("notes").set(newId(), { text: "" })
  return (
    <div style={{ maxWidth: 640, margin: "0 auto", padding: 24 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <h1 style={{ margin: 0, fontSize: 20 }}>bestiary</h1>
        <button type="button" onClick={addNote} style={{ marginLeft: "auto" }}>
          Add a note
        </button>
      </div>
      <div style={{ marginTop: 16, display: "grid", gap: 12 }}>
        {notes.map(([id, note]) => (
          <textarea
            key={id}
            value={note.text}
            placeholder="Write something…"
            onChange={(e) =>
              actions.map("notes").set(id, { ...note, text: e.target.value })
            }
            style={{ minHeight: 64, padding: 10 }}
          />
        ))}
        {notes.length === 0 && <p style={{ opacity: 0.6 }}>No notes yet.</p>}
      </div>
    </div>
  )
}

Then, still in src/tool.tsx, pass actions down from BestiaryView the same way data already flows: change the gate line to {data && actions && <Board data={data} actions={actions} />}. Same reason as Step 3's data && — both are null until the document is ready to be written to.

Render the notes
src/tool.tsx Lines 24 to 27 highlighted.

Edit and the example re-runs. Tab indents; press Escape to leave the editor.

Running · your edits, live
Starting the example…

You should see: click Add a note, and an empty textarea appears. Type in it. Reload the frame (or just trust it) — that text is stored on a real document, not in component state.

actions.map("notes").set(id, value) is the whole write API for a map field: same call whether you're creating a note or editing one, keyed by id.

Next steps