Retrieving and changing data
useDocument gives you a typed snapshot, typed mutators, and a connection status — and that is the whole read/write API.
You have a codec. Now you need to render it and change it — and you need an answer to "what do I show while it's still connecting?", because a real document arrives over a network and sometimes doesn't arrive at all.
One hook covers all of it.
You will learn
- How to read a document as typed, reactive data
- Every mutator:
set,update,list(n),map(n),transact - How to handle connecting, disconnected, and read-only without writing that UI
Before you start: this page continues the codec from Storing data.
useDocument
import { DocumentGate, defineTool, useDocument } from "@vvd/sdk"
import { codec } from "@/codec"
export default defineTool({
id: "bestiary",
name: "Bestiary",
documentTypes: ["bestiary"],
render: function View({ document, context }) {
const coords = { worldId: document.worldId, documentId: document.id }
const { data, status, actions, handle, retry } = useDocument(coords, codec)
return (
<DocumentGate status={status} onRetry={retry}>
{data && actions && (
<input
value={data.name}
disabled={!context.canEdit}
onChange={(e) => actions.set("name", e.target.value)}
/>
)}
</DocumentGate>
)
},
})Five things come back:
| What it is | |
|---|---|
data | A typed, reactive snapshot of your codec's shape. Re-renders on any change — yours or someone else's. null until the document is open. |
actions | Typed mutators. Every one of them is collaborative. null until the document is open. |
status | connecting · ready · disconnected · auth_error · error. |
handle | An opaque handle you pass to presence hooks and to <CollaborativeText>. |
retry | Rebuilds the connection. Hand it to <DocumentGate> and you're done. |
Memoise coords, or pass the same object identity between renders. A fresh
{ worldId, documentId } literal on every render is a new object, and hooks that depend on
it will do more work than they need to. In practice:
const coords = useMemo(
() => ({ worldId: document.worldId, documentId: document.id }),
[document.worldId, document.id],
)<DocumentGate> handles the states you'd rather not write
Wrap your UI in it and you inherit a skeleton while connecting, a readable error with a retry button when the connection drops, and the right message when the reader isn't allowed to open the document. Every tool on the platform gets the same treatment, so users learn it once.
You can render your own skeleton by passing Skeleton to defineTool — but you should not
be writing connection UI in your view.
The actions
Five mutators, all typed against your codec. You can't set a list, and you can't push
to a scalar — the types won't let you.
actions.set("name", "Mire Stalker") // a value field
actions.update("threat", (n) => n + 1) // read-modify-write, one transaction
actions.list("traits").push("Amphibious") // append
actions.list("traits").insert(0, "Ambush hunter") // at an index
actions.list("traits").remove(2) // remove(index, count = 1)
actions.list("traits").replace(1, "Nocturnal") // swap one item
actions.list("traits").move(3, 0) // reorder
actions.map("stats").set("speed", 40) // a keyed entry
actions.map("stats").delete("speed")
actions.transact(() => { // ONE undo step, one broadcast
actions.set("name", "Unknown creature")
actions.list("traits").remove(0, 99)
})transact
Reach for it whenever a single user gesture makes several changes. Without it, "clear this
entry" is five separate edits, and Cmd-Z undoes them one at a time — which reads as a bug to
the person pressing it. actions.transact(() => { ... }) above batches the reset into one
undo step and one broadcast, no matter how many mutators run inside it.
All of it, running
Every button below calls one of those mutators. Change the code and the tool rebuilds.
Edit and the example re-runs. Tab indents; press Escape to leave the editor.
Always gate writes on context.canEdit
context.canEdit is the platform's answer to "may this person change this document?" — it
folds in world membership, per-document sharing, published read-only views, and live
permission changes pushed from the server while the tab is open.
Disable your controls with it. Don't compute permission yourself, and don't assume that because the document opened, it's editable:
<button disabled={!context.canEdit} onClick={() => actions.list("traits").push("New")}>Reading is reactive, not fetched
data is a live snapshot. When somebody else edits the document — in another tab, on another
continent — your component re-renders with the new value. There is nothing to subscribe to,
no invalidation, no refetch, and no cache to reason about.
It also re-renders only when your codec's data actually changed. An unrelated edit in another part of the document doesn't cost you a render.
When it doesn't work
data and actions are null and stay null.
The document hasn't opened. Look at status: connecting is normal for a moment;
auth_error means the reader can't open this document; error is worth showing with
<DocumentGate>, which gives them a retry button.
Nothing happens when you call an action.
Check context.canEdit. A read-only host accepts the call and drops the write rather than
throwing, so a tool that never checks canEdit looks silently broken.
Undo undoes one tiny piece at a time.
Wrap the gesture in actions.transact(() => { … }).
Your input loses the cursor on every keystroke.
You're re-creating coords (or an extensions array) on every render. Memoise it.
Recap
useDocument(coords, codec)returns{ data, actions, status, retry, handle }and that is the whole read/write API.actions.set,.update,.list(n),.map(n)and.transactare the mutators; each one is already collaborative.<DocumentGate>covers connecting, error and retry, so you never write connection UI.dataandactionsarenulluntil the document is ready — that's the one branch every tool has.
Next steps
- Reading the world — the data that isn't yours.
- Presence — who else is in this document right now.