Step 3: Add your first field
One field, one type, and the board already knows how many notes it has.
A bestiary needs notes. Give the board a shape: a Note type, and a notes field that
holds a bunch of them, each addressed by its own id.
The pane below glues both files into one, as Step 2 warned: the // src/codec.ts and
// src/tool.tsx comment lines mark where each file begins. On disk they're two separate
files — an error about a field or the codec points at src/codec.ts; one about JSX or a
hook points at src/tool.tsx; and a name that lives in the codec but errors in the view
("Cannot find name 'Note'") means a missing import in src/tool.tsx. The merged import
block at the top splits the same way: the defineStateCodec and field imports belong to
src/codec.ts; everything else belongs to src/tool.tsx.
First, edit the src/codec.ts half — the top of the pane. Find the empty
defineStateCodec({ … }) and replace it, adding the Note type above it:
export type Note = {
text: string
}
export const codec = defineStateCodec({
// A per-KEY map: two people adding or editing different notes at once BOTH land.
notes: field.map<Note>(),
})In your real project, one more line rides along: Note lives in the codec, so the view
has to import it before it can name it —
import { type Note, codec } from "@/codec"The glued pane needs no import (it's all one file here), but on disk this is exactly the
line that fixes "Cannot find name 'Note'" in src/tool.tsx.
Then edit the src/tool.tsx half — lower down. The placeholder Board doesn't read
anything yet, so declaring the field changes nothing on screen until the view asks for it.
Replace Board with one that takes data and counts the notes:
function Board({ data }: { data: { notes: Readonly<Record<string, Note>> } }) {
const count = Object.keys(data.notes).length
return <p style={{ padding: 24, opacity: 0.6 }}>{count} notes so far.</p>
}…and in BestiaryView, change <Board /> to {data && <Board data={data} />}. The
data && guard is not decoration: data is null until the document has finished loading,
so the view must never assume the shape exists before the gate lets it through. (Stuck?
Show me writes both halves.)
Edit and the example re-runs. Tab indents; press Escape to leave the editor.
You should see: the placeholder text swaps for "0 notes so far." — the view already reads
data.notes, it just had nothing to count until now.
Why field.map, not field.list or a plain object? Each note gets edited on its own, by
id, so a map is the field whose merge behaviour matches: two people adding or editing
different notes both land, cleanly, with nothing to reconcile. The four field
types covers all of them — field.list is right
when order is the meaning, not identity.