Skip to content

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:

client.ts
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 Store
realm.update((draft) => { draft.players[me].x = 412; }); // optimistic; returns a settle handle
realm.intents.purchase({ purchaseId: newId(), itemId }); // settle-only server mutation
realm.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].

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):

client.ts
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 pipe

createWireClient 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.

await realm.whenLive(); // resolves on first hydrate; rejects on attach failure/timeout
realm.status; // "connecting" | "live" | "detached"
realm.version; // the confirmed authoritative version
realm.subscribeStatus((status) => …); // fires on EVERY transition, incl. quiet link drops
realm.detach(); // leave: sends detach, expires pending writes

If 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.

What happens to your writes when the link drops, none of which needs app code:

  1. 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.
  2. 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 and onMutationRejected fires 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. A frame_too_stale rejection 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.
  3. 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.
  4. assertOnAttach covers 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.
  5. 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.

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.

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.

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. Its reason is version_gap, frame_error or write_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, with downForMs. 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.

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.

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:

causeWhat produced itauthoritative
"confirm"Fresh server patches with no write of yours in flight — emitted as the server’s own ops, verbatimtrue
"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 locallyalways false
"hydrate"A full rebuild — the root replace abovetrue

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.

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 sample
hud.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.

@nice-code/realm/react is a thin layer over the handle:

HookWhat 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 loop
const 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 holds
const 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:

  1. Render off a narrow subscription, not the whole view. useRealmValue(realm, (v) => v.presence) (or realm.store.subscribe / listenToPatches for 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.
  2. Batch the writes to the render cadence, typically one requestAnimationFrame. Buffer the latest value in a ref and flush it as a single realm.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.

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 verdict
replica.intents.kick({ playerId }); // typed intents — the same mapped object as the client handle

Intents 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.