Durable storage
Model collaborative JSON, write typed mutations, and understand synchronization.
Room storage is a durable JSON document synchronized through Yjs. The latest state is available to current connections, and persisted updates restore the document for later connections.
Define a storage model
type Block = {
id: string;
text: string;
x: number;
y: number;
};
type Storage = {
blocks: Block[];
title: string;
};Valid storage values are strings, numbers, booleans, null, arrays, and objects composed from
those values. LiveObject, LiveMap, and LiveList are also accepted as initial values and are
normalized to JSON. Values that cannot be represented in JSON — undefined, Infinity, NaN,
Date, class instances — throw a TypeError when written.
Seed a room
The first client can supply an initial document:
<RoomProvider<Presence, Storage>
id="canvas:launch"
initialPresence={{ cursor: null }}
initialStorage={{
blocks: [],
title: "Launch plan",
}}
>
<Canvas />
</RoomProvider>initialStorage is a seed, not a reset
When the room is empty, the first connection writes initialStorage into the document. When
persisted state already exists, the remote Yjs state replaces the local seed during
synchronization. Changing initialStorage in a later release will not migrate rooms that already
have content.
Wait for storage to load before mutating
A mutation issued before the first yjs.sync arrives is applied to the local document but not yet
to the shared one. If a second client is seeding the same empty room at that moment, the two seeds
race and the losing side's content — including that early mutation — is discarded, with no error
and a final status of synchronized.
Gate writes on the room having loaded:
const storageStatus = useStorageStatus();
const ready = storageStatus === "synchronized" || storageStatus === "synchronizing";
<button disabled={!ready} onClick={addBlock} type="button">
Add block
</button>Two clients that open the same brand-new room at the same moment are the realistic case — for example a document created by one user and opened by another a fraction of a second later.
Select storage
useStorage() accepts a selector. Select the smallest value the component needs:
const title = useStorage<Presence, Storage, Events, string>(
(storage) => storage.title,
);
const blockCount = useStorage<Presence, Storage, Events, number>(
(storage) => storage.blocks.length,
);Without a selector, the hook returns the entire storage document. The selector runs on every room event, and the component re-renders whenever the room snapshot changes, so keep selectors cheap and free of side effects.
Write mutations
useMutation() receives a JsonDocumentStorage with path-based operations:
const addBlock = useMutation<Presence, Storage, [Block]>(
({ storage }, block) => {
storage.updateAt<Block[]>(["blocks"], (blocks = []) => [...blocks, block]);
},
[],
);const moveBlock = useMutation<Presence, Storage, [string, number, number]>(
({ storage }, blockId, x, y) => {
storage.updateAt<Block[]>(["blocks"], (blocks = []) =>
blocks.map((block) => (block.id === blockId ? { ...block, x, y } : block)),
);
},
[],
);The storage object supports:
| Method | Behavior |
|---|---|
getSnapshot() | Return an immutable clone of the current document |
getAt(path) | Read a value at an object/array path |
setAt(path, value) | Replace the value at a path |
updateAt(path, updater) | Derive a new value from the value at a path |
replace(value) | Replace the entire document |
subscribe(listener) | Observe storage changes |
Every method returns a clone, and every write commits a new document. Mutating a value you read
from getAt() or getSnapshot() therefore has no effect — always write the result back.
Storage paths
Object paths use strings and array paths use numbers. Try it:
Writing through a path creates the containers it needs: a numeric segment creates an array and a
string segment creates an object. Writing through an existing primitive throws a TypeError.
Use presence in a mutation
Mutations can update storage and presence through one typed context:
const selectAndMove = useMutation<Presence, Storage, [string, number, number]>(
({ setPresence, storage }, blockId, x, y) => {
setPresence({ selectedBlockId: blockId });
storage.updateAt<Block[]>(["blocks"], (blocks = []) =>
blocks.map((block) => (block.id === blockId ? { ...block, x, y } : block)),
);
},
[],
);All storage writes inside a single mutation collapse into one undo entry, so a mutation is the right unit for "one user action".
Synchronization status
Use useStorageStatus() when the UI needs exact storage state:
const status = useStorageStatus();
const label = {
"not-loaded": "Waiting for connection",
loading: "Loading shared state",
synchronizing: "Saving changes",
synchronized: "Saved",
}[status];| Status | Meaning |
|---|---|
not-loaded | The room is idle or closed; no attempt has been made |
loading | Connected, waiting for the first yjs.sync |
synchronizing | Local changes are queued or unacknowledged |
synchronized | Everything local has been acknowledged |
useSyncStatus() reduces this to "synchronizing" | "synchronized". It reports synchronizing
for loading and synchronizing, and synchronized for everything else — including
not-loaded. Prefer useStorageStatus() if a room that has not connected yet must not read as
saved.
Structured initial values
The core client exports helpers for composing initial data:
import { LiveList, LiveMap, LiveObject } from "@polynomialhq/client";
const initialStorage = new LiveObject({
blocks: new LiveList([
new LiveObject({
id: "block_1",
text: "Hello",
}),
]),
settings: new LiveMap({
grid: true,
}),
});LiveObject, LiveMap, and LiveList expose familiar mutation and iteration methods, plus
toImmutable() and toJSON().
These structures are convenience builders for the initial document. They are normalized to
plain JSON when the room starts, and useStorage() always returns plain JSON. Holding a
LiveObject reference and mutating it later does not update the room — use useMutation().
Lower-level JSON storage
createJsonDocumentStorage() can be used independently of a room:
import { createJsonDocumentStorage } from "@polynomialhq/client";
const storage = createJsonDocumentStorage<Storage>({
blocks: [],
title: "Untitled",
});
const unsubscribe = storage.subscribe(({ next, path, previous }) => {
console.log({ next, path, previous });
});
storage.setAt(["title"], "Design review");This is useful for adapters and tests. It does not connect or persist by itself.