@laioutr/valtio-liveblocks
v0.2.1
Published
Bind a valtio proxy to Liveblocks Storage (LiveObject/LiveList/LiveMap)
Readme
@laioutr/valtio-liveblocks
Bind a valtio proxy to a Liveblocks Storage root (LiveObject / LiveList / LiveMap) for two-way, conflict-free sync. Adapted from valtio-yjs.
Overview
- Local proxy mutations are mirrored into Storage; remote Storage updates are applied back onto the proxy. Both directions are granular — work is proportional to what changed, not to document size.
- A single extension point,
classify, decides whether a plain object becomes aLiveObjector aLiveMap. bindis adopt-only: it never writes to Storage during init. Storage is seeded server-side.
Install
Peers are valtio and @liveblocks/client.
pnpm add @laioutr/valtio-liveblocksUsage
import { bind } from '@laioutr/valtio-liveblocks';
import { proxy } from 'valtio';
const { root } = await room.getStorage();
const state = proxy<MyShape>({} as MyShape);
const unbind = bind(state, root, room);
// mutate `state` → Storage; remote Storage changes → `state`
unbind(); // stop syncingbind(proxy, root, room, classify?) returns an unbind function. It adopts whatever Storage already holds onto the proxy — it does not populate an empty room (see below).
Server-side: mutateRoot
mutateRoot is the server-side, one-shot complement to bind. It materialises the Storage root as a short-lived valtio proxy, runs a mutation function against it, captures the emitted ops, and replays them onto the Live tree — all inside a mutateStorage callback. The proxy is created and discarded in a single call; mutateRoot never subscribes to remote updates.
import { mutateRoot } from '@laioutr/valtio-liveblocks';
import { Liveblocks } from '@liveblocks/node';
const liveblocks = new Liveblocks({ secret });
await liveblocks.mutateStorage(roomId, async ({ root }) => {
await mutateRoot(root, async (state) => {
state.title = 'updated';
state.count = (state.count ?? 0) + 1;
state.tags.push('new-tag');
});
});mutateRoot(root, mutate, classify?) — the caller owns the mutateStorage call; mutateRoot owns the proxy lifecycle and the apply. If mutate throws, the error propagates and no ops are applied — nothing partial lands in Storage. Pass the same classify you used to seed the room so newly inserted sub-trees match the existing Storage shape.
Seeding & the adopt-only contract
bind reads Storage into the proxy and never writes during init. This keeps concurrent cold-start safe: a client that binds before its Storage view has finished syncing cannot write a fresh empty container that last-write-wins-clobbers another client's data.
Storage must therefore be populated server-side, before clients bind. Build the wire payload with jsonToPlainLsonObject and push it once via @liveblocks/node:
import { jsonToPlainLsonObject } from '@laioutr/valtio-liveblocks';
import { Liveblocks } from '@liveblocks/node';
const liveblocks = new Liveblocks({ secret });
await liveblocks.initializeStorageDocument(roomId, jsonToPlainLsonObject(data, classify));By the time a client calls bind, the room is populated and bind simply adopts it.
Classification (LiveObject vs LiveMap)
Arrays always become LiveList and scalars pass through. For plain objects, classify chooses the container:
type ClassifyFn = (path: Path, node: object, parent: unknown) => 'object' | 'map';'object'→LiveObject— fixed, known keys. This is the default (defaultClassify).'map'→LiveMap— an open-ended, id-keyed dictionary that adds/removes entries often.
Use the same classify for bind, toLson, and jsonToPlainLson* so client-bound and server-initialized rooms produce an identical Storage shape.
API
bind(proxy, root, room, classify?) => () => void— two-way sync; returns an unbind.mutateRoot(root, mutate, classify?) => Promise<void>— server-side, one-shot apply; no room or unbind required.toLson(value, classify, path?, parent?) => Lson— build a detached Live tree from plain JS.fromLson(root) => object— deep-read a Live root into a plain JS tree.applyValtioOpsToLive(root, plain, ops, classify?) => void— replay a captured op buffer onto a Live tree (lower-level primitive;mutateRootcomposes this).jsonToPlainLson(value, classify, path?, parent?) => PlainLson— plain JS → PlainLson wire format (constructs noLive*instances).jsonToPlainLsonObject(root, classify) => PlainLsonObject— root payload forinitializeStorageDocument(the Storage root is always aLiveObject).defaultClassify— classifies every object asLiveObject.- Types:
ClassifyFn,Path,PlainLson,ValtioOp.
Performance vs valtio-yjs
Measured on a large real-world JSON document (≈59.9k nodes, 3.6 MB) against a local Liveblocks dev server. Reproduce with the bundled benchmark: LB_DEV=1 BENCH_SNAPSHOT=<doc.json> pnpm exec vitest run __tests__/bench.integration.test.ts.
| Metric | Yjs (valtio-yjs) | Storage (this package) |
| --- | --- | --- |
| Build from JSON | 274.8 ms | 43.0 ms |
| Read back to plain JS | 13.0 ms | 4.3 ms |
| Retained heap | 59.8 MB | 41.5 MB |
| Encoded size | 4.1 MB | 5.37 MB |
| Per-edit round-trip (A→B) | 100.4 ms | 100.6 ms |
| Repeated edits to one field | op-log grows | tracks state, not edit count |
Storage builds ~6× faster, reads ~3× faster, and uses ~30% less heap; realtime latency is the same (~100 ms, network-throttle-bound). Yjs is slightly smaller on the wire but stores the whole tree as one monolithic document, whereas Storage shards per node and grows with state rather than edit history. (Storage must be seeded server-side — see above.)
Caveats
- One mutation path per bound root. Write through the proxy or through
room.batchdirectly — not both for the same root. Same-tick writes to different keys co-exist; a same-key collision resolves last-write-wins. undefinedvsnull. Storage cannot holdundefined: writes lowerundefined→nulland reads collapsenull→undefined. Model optional fields asfield?: T, neverfield: T | null.- Array element identity is not preserved across a structural remote change (insert / delete / move): values converge, but a proxy array element may be re-created rather than mutated in place.
- Unsupported values (
Date,Map,Set, class instances) are not serializable — they warn and storenull. Use plain, JSON-compatible values.
