Skip to content
Guides— browse docs
On this page

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.

src/codec.ts
import { defineStateCodec, field } from "@vvd/sdk"

export const codec = defineStateCodec({
  name: field.value("Ashfall Drake"),
})

export default codec

defineStateCodec 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.

One document, two people
Type in either side.
Starting the example…

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

src/codec.ts
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
FieldUse it forWhat happens when two people write at once
field.value(default)a title, a mode, a colour, any config scalarone of the two wins, consistently, everywhere
field.list(default?)ordered items — pins, rows, steps, sightingsboth inserts land, in a stable order everyone agrees on
field.map()keyed data — votes by id, settings by keyper key: different keys never conflict; the same key resolves like a value
field.prose()a rich-text bodymerged 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:

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

Your codec, live
src/tool.tsx

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

Running · your edits, live
Starting the example…
Note:

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:

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

src/codec.ts
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.value is last-write-wins, field.list is positional and both insertions land, field.map merges per key, field.prose merges per character.
  • A codec is a declaration: adding a field later needs no migration, and old documents read the default.
  • defineStateCodec is the only place the CRDT is touched — you never import Yjs.

Next steps