Step 6: Two people, one sheet
The reason for every choice in this build, watched instead of explained.
Nothing left to build — this step is for watching. Two panes below, one document, exactly like Getting Started's "Two people at once". Add a row in both panes at the same time.
First, a condensed recap of what you built in Steps 2–5 — the same tool, trimmed to the parts that matter here:
import { DocumentGate, type ToolRenderProps, defineStateCodec, defineTool, field, useDocument } from "@vvd/sdk"
// src/codec.ts
export type Row = { item: string; quantity: number; price: number }
export const codec = defineStateCodec({
rows: field.list<Row>([
{ item: "Rope (50 ft)", quantity: 2, price: 1 },
{ item: "Torch", quantity: 6, price: 0.5 },
]),
})
// src/tool.tsx
function Board({ data, actions }: {
data: { rows: readonly Row[] }
actions: { list(name: "rows"): { push(...items: Row[]): void } }
}) {
const addRow = () => actions.list("rows").push({ item: "", quantity: 1, price: 0 })
return (
<div style={{ padding: 16 }}>
<button type="button" onClick={addRow}>Add a row</button>
<table>
<tbody>
{data.rows.map((row, i) => (
<tr key={i}>
<td>{row.item}</td>
<td>{row.quantity}</td>
<td>{row.price}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export function ArmoryView({ document }: ToolRenderProps) {
const { data, status, actions, retry } = useDocument(
{ worldId: document.worldId, documentId: document.id },
codec,
)
return (
<DocumentGate status={status} onRetry={retry}>
{data && actions && <Board data={data} actions={actions} />}
</DocumentGate>
)
}
export default defineTool({
id: "armory",
name: "armory",
documentTypes: ["armory"],
needs: [],
render: ArmoryView,
})Two new rows, not one overwriting the other. That's field.list doing exactly what Step
2 said it would — nobody wrote any
merge logic, and nothing needed a conflict dialog.
Where this gets harder
This build's rows are edited by index, which is fine for a sheet where people mostly add
rows and mostly edit their own. Once a lot of people are editing the same rows at once —
renaming columns, retyping a cell someone else is also touching — you want cells addressed
like a real spreadsheet instead: see the Sheet Starter Kit's
field.map-keyed grid and its answer to "why is every
cell one map entry instead of a list of rows?"
Recap
- A row's shape is a plain TypeScript type;
field.list<Row>()is what makes a list of them mergeable — afield.valueholding an array is not. actions.list("rows")gave this build three operations —push,replace,remove— and every edit was one of them;insertandmoveround out the surface (SDK reference).- A value computable from other fields (a Total) is never stored; it's computed in render,
every time, from
data.rows. - Pasting is just parsing
clipboardDataintoRow[]and callingpush— no special API. - Two peers editing the same document need zero sync code, because the field type already decided how concurrent writes converge.