Skip to content
Tools— browse docs
On this page

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:

src/codec.ts + src/tool.tsx
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,
})
One armory, two people
Add a row in both panes, as fast as you can.
Starting the example…

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.

Note:

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 — a field.value holding an array is not.
  • actions.list("rows") gave this build three operations — push, replace, remove — and every edit was one of them; insert and move round 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 clipboardData into Row[] and calling push — no special API.
  • Two peers editing the same document need zero sync code, because the field type already decided how concurrent writes converge.

Where to next