Skip to content
Get started— browse docs
On this page

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:

src/codec.ts
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 —

src/tool.tsx (real project)
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:

src/tool.tsx
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.)

Add the notes field
src/codec.ts + src/tool.tsx Lines 13 to 15 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: the placeholder text swaps for "0 notes so far." — the view already reads data.notes, it just had nothing to count until now.

Note:

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.

Next steps