Storing data
Declare the shape of your document once, and get a collaborative, persistent, mergeable store with no sync code.
Often you'll want two people editing the same thing at once without either of them losing
work. You already know how to do it for one person — useState, a save button, a PUT. Add
a second person and it stops working: the last save wins, the other person's paragraph
disappears, and nobody knows it happened.
That's the problem this page solves, and it's the one thing you must not hand-roll on vvd.
You will learn
- Why document data goes in a codec instead of
useState - The four field types, and which merge behaviour each gives you
- How to add a field later without a migration
The smallest version
You declare a shape. That's the whole API.
import { defineStateCodec, field } from "@vvd/sdk"
export const codec = defineStateCodec({
name: field.value("Ashfall Drake"),
})
export default codecdefineStateCodec hands back a codec — the typed shape of one document. Reading it gives
you { name: string }. Writing it gives you actions.set("name", "Mire Stalker"). Between
those two, the platform stores it, syncs it to every other person looking at the document,
persists it, and gives you undo.
There is no useState for name, no save button, and no fetch.
Why a codec and not useState
Because useState has no answer to two of you.
Below is one document mounted in two panes — two independent copies of your tool, exactly as if two people had it open. Type in either one.
Press Add a trait in both panes as fast as you can. You get two new chips — both panes computed the same label, and both inserts still landed. Nothing was overwritten and nothing needed a conflict dialog.
Now type in the name field in both panes. It's a scalar, so the two writes converge on one winner rather than flickering forever — which is exactly what you want for a title and exactly what you don't want for a list.
You didn't write a line of that. It's a property of the field type you chose, which is why choosing well is most of the work on this page.
Why a CRDT and not last-write-wins?Deep dive
Last-write-wins is the obvious design and it's fine for one person. What breaks it is that "last" is a lie: there's no shared clock, so "last" means "whichever write the server happened to see second", and that depends on which of you had the worse Wi-Fi. Two people adding a trait at the same moment produce two writes, the server keeps one whole document, and one trait vanishes with no error anywhere — the write succeeded.
The usual patches all cost you something. Locking means one of you waits. Version numbers plus a conflict dialog means somebody has to read a diff mid-sentence. Operational transform works, but only with a server that understands your data, which rules out ever working offline.
A CRDT takes a different bet: make the merge a property of the data structure rather than a decision someone has to make. Every insert carries enough identity that any two replicas applying the same set of operations — in any order, after any amount of time apart — end up byte-identical. There's nothing to resolve, so there's nothing to ask the user about, and it works the same whether the other person is on the next desk or was offline for a week.
The cost is real and it's the reason the field types exist: a CRDT can only merge the way its
structure merges. A list merges as a list. A counter can't be built out of field.value,
because two people setting 5 and 5 converge on 5 rather than 10. That's the trade,
and it's why picking the field type is the design work.
vvd's CRDT is Yjs. You won't import it — defineStateCodec is the only
module that touches it — but if you go looking, that's what's underneath.
The four field types
import { defineStateCodec, field } from "@vvd/sdk"
export type Sighting = { where: string; when: number }
export const codec = defineStateCodec({
name: field.value("Ashfall Drake"), // last-write-wins scalar
sightings: field.list<Sighting>(), // positional — concurrent adds BOTH land
stats: field.map<number>(), // per-key last-write-wins
lore: field.prose(), // rich text — character-level merge
})
export default codec| Field | Use it for | What happens when two people write at once |
|---|---|---|
field.value(default) | a title, a mode, a colour, any config scalar | one of the two wins, consistently, everywhere |
field.list(default?) | ordered items — pins, rows, steps, sightings | both inserts land, in a stable order everyone agrees on |
field.map() | keyed data — votes by id, settings by key | per key: different keys never conflict; the same key resolves like a value |
field.prose() | a rich-text body | merged per character, with live carets |
One list edge worth knowing: the move action (actions.list(name).move(from, to)) is a
delete plus a re-insert under the hood, so when two people concurrently move the same
item, the two removals merge into one but both re-inserts land — the item ends up
duplicated, once at each destination. If items in your tool get rearranged concurrently,
give them stable ids and dedupe by id when you render, or reach for field.map.
The rule of thumb: pick the field whose merge you want. Storing everything as one
field.value holding a JSON blob technically works and is the single most common mistake —
two people editing different parts of that blob will clobber each other, and the platform
can't help because as far as it knows they edited the same thing.
Choosing between them, concretely
Three versions of the same idea:
// 1. A blob. Two people editing different creatures overwrite each other. Don't.
creatures: field.value<Record<string, string>>({})
// 2. A list. Great when order matters and items are added at the ends.
creatures: field.list<string>(["Ashfall Drake"])
// 3. A map. Great when items are addressed by a stable id and edited independently.
creatures: field.map<{ name: string; threat: number }>()Reach for field.map when each item has an identity and gets edited on its own. Reach for
field.list when the sequence is the meaning.
Edit the shape and watch it change
This is the same tool with the editor attached. Change a default in the codec, add a trait to the seeded list, or add a whole field and render it — the running tool rebuilds as you type.
Edit and the example re-runs. Tab indents; press Escape to leave the editor.
Why export const codec, not const codec? The defaults you declare are baked into the
document's starting state, and the thing that applies them needs to be able to find your
codec. Export it — which is what the scaffold does anyway — and a fresh document opens with
your field.list defaults already in it. A module-private codec still runs; its list
defaults won't be there on the first render.
Note the asymmetry while you're here: field.value and field.list take a default,
field.map does not — a map always starts empty, so read it with a fallback
(data.stats.threat ?? 1).
Rich text is a field too
field.prose() is the one field you don't read as a value. It gives you a token you hand to
the SDK's editor component, which renders a real rich-text surface with live carets:
import { CollaborativeText, useDocument } from "@vvd/sdk"
const { data, handle } = useDocument(coords, codec)
// `data.lore` is the field token; `handle` is the opaque document handle.
<CollaborativeText handle={handle} field={data.lore} placeholder="Lore…" />You get character-level merge, other people's carets, and the standard formatting keys, and you never touch the editor's internals.
Adding a field later
No migration. A document that predates a field reads that field's default, so this is always safe:
export const codec = defineStateCodec({
name: field.value("Ashfall Drake"),
traits: field.list<string>(),
habitat: field.value("Unknown"), // ← added today; old documents read "Unknown"
})Renaming or reshaping an existing field is a different matter and needs a real migration — but adding is free, forever, and it's the reason it's fine to start with two fields.
Keep src/codec.ts pure
No React, no UI imports, nothing with side effects. When you ship, vvd save executes this
module to derive your agent surface and your search index from the one declaration —
Ship it shows exactly
what it produces. Import a component here and that derivation fails with a warning at save
time, and you'll ship a tool no agent can operate.
When it doesn't work
A field.list default renders as an empty list.
The codec has to be exported for its defaults to be applied to a fresh document — see the
callout above. export const codec = defineStateCodec({ … }).
Two people's edits clobber each other.
Almost always a blob: several independent things stored in one field.value. Split them
into their own fields, or into a field.map keyed by id.
couldn't read @/codec to derive the agent/index blocks at save.
Something in src/codec.ts can't run outside a browser — usually a UI import, or code that
touches window at module scope. Move it into src/tool.tsx.
Your list doubles when a second person joins.
You're seeding the list from your component instead of from the codec's declared default.
Put defaults in field.list([...]) and let the platform apply them once.
Recap
- Document data lives in a codec, not
useState, because two people edit it at once. field.valueis last-write-wins,field.listis positional and both insertions land,field.mapmerges per key,field.prosemerges per character.- A codec is a declaration: adding a field later needs no migration, and old documents read the default.
defineStateCodecis the only place the CRDT is touched — you never import Yjs.
Next steps
- Retrieving and changing data —
useDocument,actions, and the connection states you have to handle.