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:
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.
Edit and the example re-runs. Tab indents; press Escape to leave the editor.
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.