Connecting & React
The client side of a realm: connecting over your app's connection, status and liveness, the staleness probe, the patch feed, replicas, and the React hooks.
A realm is live shared state that the server owns and clients watch. It has no socket of its own; it attaches to your app’s connection: one secure socket, one handshake, one authenticated identity. The connection comes from @nice-code/wire, and realms and actions can share it. Open it with createWireClient when the realm is the only traffic and you don’t use @nice-code/action at all (see Realm-only apps below), or with connectChannel when the app also has actions and both share the one socket:
import { connectChannel } from "@nice-code/action";import { connectRealm, realmConnection } from "@nice-code/realm";
// The app's connection — the realm rides it (alongside actions, or alone).const connector = connectChannel(runtime, gameChannel, { peer, storage, carriers });
const realm = connectRealm(gameRealm, { avatar: { type: "player" }, // persistentId defaults from the authenticated coordinate connection: realmConnection(connector), onMutationRejected: ({ error, rolledBackPaths }) => toastRollback(error, rolledBackPaths),});
const me = realm.avatar.persistentId; // who this handle is to the realm
realm.store // @nice-code/state Storerealm.update((draft) => { draft.players[me].x = 412; }); // optimistic; returns a settle handlerealm.intents.purchase({ purchaseId: newId(), itemId }); // settle-only server mutationrealm.pending // { count, has(prefix), subscribe }realm.store is a regular @nice-code/state Store holding the projected view. Read it, subscribe to it, and bind it to React like any other store. The projection starts empty and fills on hydrate, the first full copy the server sends. connectChannel itself (the peer coordinate, storage, and carriers) is covered in Serving & Connecting; the realm-only sample below shows a complete call.
avatar.type is always explicit, because it is realm-specific. persistentId/instanceId default from the connection’s authenticated coordinate when it has one, so pass them explicitly only for tests and replicas. If the server resolves your identity differently than the client believes (a resolver mismatch), the client warns loudly once. The symptom would otherwise be “prediction passes locally, server rejects every time”.
The resolved identity is on the handle as realm.avatar (type, persistentId, instanceId). Use realm.avatar.persistentId to key your own entries, such as draft.players[realm.avatar.persistentId].
Realm-only apps — createWireClient
Section titled “Realm-only apps — createWireClient”A realm-only app has no actions, so it needs no @nice-code/action at all: no ActionRuntime and no channel. Open the connection directly with createWireClient from @nice-code/wire and bind the realm onto it. It takes the facts every secure connection needs: who you are (identity), who you’re dialing (peer), how you prove it (storage), and how you reach it (carriers):
import { connectRealm, realmConnection } from "@nice-code/realm";import { createWireClient, wsCarrier } from "@nice-code/wire";
const client = createWireClient({ identity: myCoordinate, // this client's authenticated coordinate peer: serverCoordinate, // the acceptor it dials storage, // persists the crypto identity (TOFU) carriers: [wsCarrier(url)],});
const realm = connectRealm(gameRealm, { avatar: { type: "player" }, // persistentId defaults from `identity` connection: realmConnection(client),});await client.connect(); // dial + secure handshake; the realm hello follows on the same pipecreateWireClient returns the same kind of connection connectChannel does. realmConnection binds to either structurally, so connectRealm doesn’t know or care which one opened the socket. Everything on this page below the connection (heartbeat, link events, the redial ladder, the staleness probe) is identical for both, because it all lives on the wire connection, not on what uses it. connectChannel is the action-side twin: the same peer/storage/carriers, plus a channel and onPush. See Serving & Connecting.
With actions in play, the first action call dials for you. A realm-only app calls client.connect() itself.
Status & liveness
Section titled “Status & liveness”await realm.whenLive(); // resolves on first hydrate; rejects on attach failure/timeoutrealm.status; // "connecting" | "live" | "detached"realm.version; // the confirmed authoritative versionrealm.subscribeStatus((status) => …); // fires on EVERY transition, incl. quiet link dropsrealm.detach(); // leave: sends detach, expires pending writesIf the server refuses an attach (schema-hash mismatch, unknown realm, unresolvable identity), the error appears on realm.attachError and whenLive() rejects.
You can write before whenLive(). The projection is {} until the first hydrate, so a realm.update() or intent called in that window is queued instead of run. When the hydrate lands, the queue runs in call order, after assertOnAttach, and each updater sees the hydrated state. The handle you got back settles like any other write. While a call is queued it counts in realm.pending.count, but nothing shows in the store yet.
A queued call never hangs. If the server refuses the attach, its handle rejects with realm.attachError. If the realm detaches first, or pendingExpiryMs passes before the hydrate, it rejects with write_before_hydrate, which means nothing was sent. A queued updater that throws rejects its handle and logs a console.error, because there is no caller left to throw to. Durable (durability: "reassert") writes skip the expiry, as they do once sent.
You still need whenLive() (or hasBeenLive) before you read the store. State that must exist whenever you’re attached belongs in one place. Declare it once:
assertOnAttach — state you must own while attached
Section titled “assertOnAttach — state you must own while attached”const realm = connectRealm(lobbyRealm, { avatar: { type: "player" }, connection: realmConnection(connector), // Runs after EVERY full hydrate: the first attach, an out-of-log-window resume, // and an epoch change (a wiped or ephemeral server that cold-started). assertOnAttach: (draft) => { draft.players[realm.avatar.persistentId] = currentEntry(); },});It is an ordinary optimistic write: rules apply, and a rejection reaches you once through onMutationRejected. The library re-runs it exactly when your entry could have been lost. On a fully durable realm that means full hydrates only: an incremental patch-replay resume skips it, because your entry is still confirmed state. A realm with a transient region also runs it after every replay resume (a reconnect, a wake re-hello, the staleness probe), because a server wake empties that region without a full hydrate. It resolves before whenLive() continuations run, so “await whenLive, read my entry” always sees it. This one option replaces the hand-rolled join/rejoin/re-assert stacks presence apps build; the Presence Realms guide is built around it.
Reconnects & durability
Section titled “Reconnects & durability”What happens to your writes when the link drops, none of which needs app code:
- Pending writes resend automatically. Every unconfirmed optimistic write survives a disconnect. On reattach it is rebuilt against the fresh server state and resent, whether the server resumes you with a patch replay or a full hydrate. A flaky socket does not lose writes, and you don’t need to re-issue anything on reconnect.
pendingExpiryMs(default 10 s) is the only loss window. It bounds how long an optimistic write waits to settle; it is not a delivery deadline. Past it, the write’s UI effect rolls back andonMutationRejectedfires with the rolled-back paths. It is per-handle. A producer whose screen renders from local truth (a game host drawing from its own simulation) can raise it freely, because the usual cost of a long window, stale UI that snaps back, doesn’t apply there. Aframe_too_stalerejection is an unknown outcome, not a definite “no”: the write may have committed on the server while the ack was lost. Don’t blindly re-issue a non-idempotent intent on it. Re-read state, which converges on the next replay/hydrate, and decide. A rules rejection, by contrast, carries the rule’s own error and is a definite reject.durability: "reassert"removes the window entirely for regions the client authoritatively produces, such as append-only logs. Those frames resend across reconnects, and across epoch changes, until the server confirms or rejects them. See Durable writes.assertOnAttachcovers the confirmed-then-lost case: state the server once had but lost to a wipe or an ephemeral cold start. Durability covers never-confirmed writes, and the assert hook covers confirmed ones. A presence realm typically wants only the hook; a producer wants only the tier.- The staleness probe heals reads, and is not a redelivery mechanism. It re-syncs what you see (below) and never rescues an expired write. Write durability is points 2–4.
One boundary stays. A durable exchange that needs a data response, such as submitting a score or anything with an ack payload the app must act on, belongs to actions: see reliable delivery, which has its own 10-minute-class redelivery deadline.
Who re-dials the link — nothing here needs app code either
Section titled “Who re-dials the link — nothing here needs app code either”A realm is push-driven, so a permanently dead socket is never the right steady state. The connection owns its own reconnection. When a realm uses a wire link (from createWireClient or connectChannel), an unexpected drop redials automatically with exponential backoff and jitter (≈1 s → 30 s). On success the mux (the layer that lets realms and actions share one socket) re-attaches, and every realm re-hellos and resumes. You don’t dispatch anything or call connect() again. Opt out with reconnect: false when your app manages its own connection lifecycle. An explicit .clearTransportCache() or .dispose() on the client/connector always stops it. A pure-action connection is unchanged and keeps its dispatch-driven reconnect.
The realm’s own staleness probe (below) is the other half. It catches a half-open socket, one that looks open but silently swallows sends, and asks the connection to re-dial. Together they mean a game client recovers from both a clean drop and a network blip without app-level plumbing.
The staleness probe
Section titled “The staleness probe”On by default (staleProbeAfterMs: 25_000; 0 disables). A live handle that hears no realm frame for the window quietly re-hellos with its last-known version and log epoch. The probe is idempotent by design:
- a current client gets an ~10 B empty replay (which itself re-arms the timer);
- a stale client gets exactly the missed patches;
- a wiped or re-created server forces a clean full hydrate. The epoch names the history, so version numbers from a different past never replay as a diff.
Lost wake broadcasts, missed frames, suspended tabs: every “silently stale forever” failure converges on quiet too long → probe → resync. The probe never flips status, so an idle-but-healthy connection shows no UI flap.
Escalation (probeReplyTimeoutMs, default 10 s; 0 disables). A half-open socket answers the probe’s re-hello with silence. If no frame arrives within this window after a probe, the link is presumed dead: status flips to "connecting", a probe_escalated diagnostic fires, and the connection is asked to re-dial (the reconnect-ownership above). So the worst-case time to notice a dead-but-open socket is roughly staleProbeAfterMs + probeReplyTimeoutMs, ≈35 s by default. Tune both down for a twitchier game.
Observing sync health — onDiagnostic
Section titled “Observing sync health — onDiagnostic”Wire it in production. A healthy realm emits nothing. It reports automatic recoveries, so you can alarm on their rate instead of learning about a bad link from user reports:
connectRealm(gameRealm, { connection: realmConnection(connector), onDiagnostic: (event) => metrics.count(`realm.${event.type}`), // the event types are listed below});resync— lost sync was detected and healed by an automatic re-hello. Itsreasonisversion_gap,frame_errororwrite_target_missing(the server rejected a write whose target it no longer had).gap_dropped— a non-continuous frame that crossed an in-flight recovery was dropped (expected during a resync; benign).frame_error— an inbound frame failed to decode/apply and triggered a resync (should be ~never; investigate if it recurs).probe_escalated— a probe went unanswered and the link was re-dialed (above).late_reject— a reject arrived for a write that had already settled. Harmless, but worth logging.reown_missing— a transient entry you own went missing and recovery did not re-create it.link_redialed— the link came back after a drop, withdownForMs. It is the one transport event forwarded here, and it does not mean sync was lost.
The last three are explained in who sees what.
Even unwired, the library leaves a floor. The first occurrence of each diagnostic type logs one console.warn. A rolled-back optimistic write with no onMutationRejected handler warns once too, so a “my move silently vanished” bug is never fully invisible. Wire both handlers to replace the warnings with real telemetry.
Limits are advertised and livable
Section titled “Limits are advertised and livable”The hydrate advertises the server’s ingress limits. An over-limit coalesced flush splits into consecutive ≤maxPatchesPerFrame frames. Each part is server-atomic, and all handles settle with the last part, so read-your-writes holds. A limits_exceeded reject retries instead of settling fatally: it re-splits against a halved ceiling, or backs off ~1 s for rate breaches. A well-behaved client stays attached under load without you tuning anything.
The patch feed (imperative renderers)
Section titled “The patch feed (imperative renderers)”React re-renders from the store. A canvas, a typed array or a WebGL scene wants deltas, not snapshots:
realm.listenToPatches((patches) => board.applyPatches(patches));The feed emits the projected-store delta per flush, in name-space immer patch shape:
- a confirmed-only flush with no optimistic overlay emits the authoritative patches verbatim;
- a rebase flush emits a structural diff, identity-pruned via immer’s structural sharing (unchanged subtrees skip in O(1));
- a hydrate, or any full rebuild, emits exactly
[{ op: "replace", path: [], value }], the “rebuild everything” signal.
Listeners fire after the store flush, so the store already reflects the values. Apply the patches to your render target instead of re-diffing the whole state per flush.
Every flush says whether it carried server truth
Section titled “Every flush says whether it carried server truth”The listener’s second argument carries meta.authoritative: whether this flush was new server truth or your own echo.
realm.listenToPatches((patches, meta) => { if (!meta.authoritative) return; // your own optimistic echo / a local rollback reconcile(patches, meta.confirmedVersion); // fresh server state — with or without your writes in flight});That is the whole rule for a predictor or reconciler. If you react to a non-authoritative flush, you replay your own un-acked input echo as if the server had confirmed it. That bug type-checks, renders plausibly, and re-adds the full round trip as input lag.
meta.cause is the companion field. It describes how the flush was produced, not whether it carried authority:
cause | What produced it | authoritative |
|---|---|---|
"confirm" | Fresh server patches with no write of yours in flight — emitted as the server’s own ops, verbatim | true |
"rebase" | A rebuilt projection, emitted as a structural diff: fresh server patches landing while you have writes in flight, or a purely local rebuild (an expiry/reject rollback) | true / false — check the field |
"optimistic" | Your own write/intent applying locally | always false |
"hydrate" | A full rebuild — the root replace above | true |
One behavioural note if you are moving a render path from store.subscribe to this feed: listenToPatches emits only when the projection actually changed, so an empty diff fires nothing. store.subscribe fires on every flush.
Measuring the link — realm.stats
Section titled “Measuring the link — realm.stats”Every handle keeps a live link-quality estimate, fed by traffic the realm already sends: write-confirm round trips (measured from the frame’s send, so a coalescing window is never counted as network) and answered hellos (the staleness probe’s ~10 B empty replay):
const { rttMs, jitterMs, samples } = realm.stats; // EWMA-smoothed; null until the first samplehud.setPing(rttMs == null ? "—" : `${Math.round(rttMs)} ms`);Poll it from a HUD or render tick. The getter is O(1) and allocation-free apart from the result object. Two points are deliberate. There is no dedicated ping frame, because a realm-level ping would wake a hibernated server only to be measured: an idle spectator would keep every match’s Durable Object hot and billed. And samples come from existing traffic, so a write-active client samples constantly while a read-mostly client samples at its probe cadence. If a spectator view wants denser samples, lower staleProbeAfterMs deliberately. That is a documented trade (more probe traffic, denser RTT), not a hidden default.
React hooks
Section titled “React hooks”@nice-code/realm/react is a thin layer over the handle:
| Hook | What it gives you |
|---|---|
useRealm(realm) | The whole projected view (re-renders on any change — prefer useRealmValue). |
useRealmValue(realm, (v) => slice) | A derived slice; re-renders only when the slice changes. |
useRealmStatus(realm) | { status, version, pendingCount } — connection UI. |
useRealmPending(realm, "players.p1") | true while writes under the prefix are unconfirmed — the “saving…” indicator. |
useRealmIntent(realm, "purchase") | { run, status, error } — classic submit → spinner → done, zero rollback risk when the intent has no predictor. |
Reading before hydrate: selectors must return a stable reference
Section titled “Reading before hydrate: selectors must return a stable reference”Writes made before the first hydrate are queued for you. Reads are not, and it’s easy to miss because the types hide it. Before the first hydrate, realm.store.state is genuinely {}, with no keys at all, regardless of what TView declares. A mounted component reads the store on every render, so a direct page load renders your selectors against that empty {} at least once, before any network round-trip. TView says presence is always there. The runtime value in that window says otherwise.
That collides with a useSyncExternalStore rule that useRealmValue inherits: a selector must return the same reference when nothing changed. A selector that fills a default for a possibly-absent field with an inline literal —
// ✗ allocates a fresh {} every call while state is still empty → infinite render loopconst presence = useRealmValue(realm, (v) => v.presence ?? {});— returns a new object on every getSnapshot, so the hook sees “changed” every time and React trips its own guard: “The result of getSnapshot should be cached to avoid an infinite loop”, then “Maximum update depth exceeded”. The fix is a stable fallback reference (a module constant, useRef, or useMemo) —
const EMPTY_PRESENCE: TView["presence"] = {};// ✓ same reference every call → the hook's strict-equality fast path holdsconst presence = useRealmValue(realm, (v) => v.presence ?? EMPTY_PRESENCE);— or a non-strict comparator as the third argument, useRealmValue(realm, sel, deepEqual), when the slice is genuinely recomputed each call. The same trap applies to any narrow selector that builds a value (.filter, .map, an object literal) instead of returning a state branch as-is.
Performance: pointer/cursor-frequency direct writes
Section titled “Performance: pointer/cursor-frequency direct writes”Every realm.update() call, even one that’s about to coalesce with others, synchronously commits to realm.store and notifies every subscriber before returning. coalesceMs only delays the wire send, never the local commit. Wire that straight to a native event that can fire dozens of times a second (pointermove, drag, a paint stroke) and you drive your renderer at native input frequency. That is often more than the display can show, and well above what a remote peer’s copy of the same path does: theirs arrives already coalesced by their sender’s window plus network latency, in sparser jumps.
The result is a real, counter-intuitive symptom: your own cursor can look laggier than a peer’s, even though it’s always working from the freshest possible value. Remote motion looks smooth because it’s sparse, and a CSS transition (or any interpolation) fills the gaps nicely. Local motion re-renders, and restarts any transition, every single frame, which reads as trailing instead of live.
Two changes fix it, and a hot direct-write path generally wants both:
- Render off a narrow subscription, not the whole view.
useRealmValue(realm, (v) => v.presence)(orrealm.store.subscribe/listenToPatchesfor a canvas/WebGL target) re-renders only what actually watches that slice. Put it in its own component (or render target) so a cursor move doesn’t also re-render a notes list, a HUD, or anything else sharing the page. - Batch the writes to the render cadence, typically one
requestAnimationFrame. Buffer the latest value in a ref and flush it as a singlerealm.update()call at most once per frame, instead of once per native event. There’s no benefit to writing, or re-rendering, faster than the screen refreshes.
Apply any interpolation (CSS transitions, a lerp) only to remote entities. Your own value already updates every frame, so smoothing it adds a fixed visual lag with nothing to hide.
Worried about network volume, not render cost? That’s coalesceMs
Section titled “Worried about network volume, not render cost? That’s coalesceMs”The two fixes above are render-side and don’t change how much goes over the wire. Wire volume is a separate, already-solved axis: connectRealm(realm, { coalesceMs }). Every update() inside the window merges into one pending frame, collapsed last-write-wins per path (twenty presence.p1.x writes become one patch), and that frame ships once when the window closes. Sends cap at 1000 / coalesceMs per second no matter how often you call update(). So coalesceMs: 40 is ~25 frames/sec/client, whether the writes arrive at raw pointermove rate or already rAF-batched.
It’s a fixed-window throttle, not a resetting debounce. The window doesn’t restart on each new write, so a continuously-moving cursor keeps flushing every window and never goes silent until motion stops. A debounce would go silent, which is exactly wrong for live presence. Reach for it on any pointer-frequency direct-write path. It is independent of the render fixes above, and a hot path usually wants all of them.
One edge to know before you set it: the coalesceMs window is trailing-edge. The first write arms it and the frame ships when it closes, so an isolated write (a keyboard transition after seconds of silence) waits out the whole window as added latency before it reaches the network. coalesceMs is for paths that write continuously. For a connection that carries both duty cycles, coalesce: { ms, leading: true } sends the first write of a quiet period immediately and coalesces the burst behind it. A single fast lane can use realm.update(fn, { flush: "immediate" }) instead. Full duty-cycle guidance: Coalescing.
Replicas
Section titled “Replicas”A Worker (or any process) that wants a live read copy connects as a replica: a matchmaker reading lobby state, say, or an analytics tap. A replica is a plain object plus a patches callback, with no Store/React stack and no special authority. Its powers are whatever its avatar type’s rules grant:
import { connectRealmReplica } from "@nice-code/realm";
const replica = connectRealmReplica(lobbyRealm, { avatar: { type: "replica", persistentId: "matchmaker" }, connection: realmConnection(connector), onPatches: (patches, { version }) => index.apply(patches),});
replica.state; // the confirmed slice (plain object, replaced per batch)replica.update(…); // non-optimistic write — settles on the server verdictreplica.intents.kick({ playerId }); // typed intents — the same mapped object as the client handleIntents are compile-typed on the replica handle exactly as on the client handle, so a misspelled intent is a compile error, not a runtime intent_unknown. For dynamic names there is an untyped replica.intent(name, input) fallback; prefer the mapped object.
Replicas get the same subscribeStatus, staleness probe (with probeReplyTimeoutMs escalation), onDiagnostic, and reconnect ownership as full clients. They live longer, so silent staleness matters more, not less. A replica’s update/intent is non-optimistic, but its handle is still bounded: an unacked write is resent on every recovery and rejects frame_too_stale after pendingExpiryMs (default 10 s) instead of hanging forever. So await replica.update(...) always settles.