| name | jotai |
| description | Jotai 2.19+ atom authoring for Snapmatch — the reactive optimistic projection published after durable IDB commits, never Firestore authority; covers commands, reconciliation, sync lifecycle, and local-only atomWithIDB state. |
| license | MIT |
The in-memory reactive layer over durable IDB state. Generated settings/progress remain IDB-local
through atomWithIDB. Remote-owned state uses the implemented remoteProjectionAtom,
syncLifecycleAtom, durable enqueue action, and reconciliation action. Firestore is authoritative,
IDB is the validated committed replica plus outbox, and Jotai is the optimistic view used by React.
Jotai never becomes a second authority or treats a local set/network echo as acknowledgement.
When to invoke
- Authoring an atom for game progress, settings, content, or another persistent projection.
- Writing the
atomWithIDB(schema, key, default) factory (or extending it). The IDB primitives + root suspense pattern this factory plugs into are owned by idb.
- Designing a command atom or reconciliation action for Firestore-owned state.
- Choosing between
useAtom, useAtomValue, and useSetAtom.
- Adding a derived atom (sync or async), or a per-id parameterized atom (use the
atomWithIDB key or selectAtom — not atomFamily).
- Consuming the cross-tab change broadcast (the channel itself is owned by
idb) to refresh affected atoms.
Owns
Jotai atom authoring, hooks (useAtom, useAtomValue, useSetAtom), derived atoms, parameterized
atoms (via the IDB key or selectAtom — not atomFamily), store/Provider, optimistic reducers,
and reconciliation actions. The current atomWithIDB parse-on-set contract remains appropriate for
local-only seed state. IDB primitives, hydration, atomic replica/outbox writes, and
BroadcastChannel invalidation are owned by idb; Firestore transport is owned by the sync layer.
Defers to
zod — for how to author the schema each atomWithIDB(schema, key, default) validates against. The schema is the producer; the atom's job is to call schema.parse on every set and surface the failure.
idb — for IDB primitives: the singleton getDB(), the typed IDBPDatabase<AppDB>, the root idbHydrationPromise this skill's atoms use(), and the debounced write-through + BroadcastChannel plumbing the factory wires into.
- The Firestore sync layer — for snapshot subscriptions, command delivery, acknowledgements,
retries, and server conflict policy. It hands validated durable changes to Jotai reconciliation.
react-19-primitives (Wave 3, forward) — for the <Suspense> boundary and the use(promise) call that fronts idbHydrationPromise.
bun-test — for unit-testing pure atom reducers with createStore().
Snapmatch stack rules
- Local-only state:
atomWithIDB remains the factory for existing IDB-local seed/progress state.
The hydration promise resolves once and those atoms read the migrated in-memory snapshot
synchronously. IDB removes expired outbox evidence, receipts, tombstones, and recovery rows before
that snapshot reaches Jotai. Retained RecoveryRecords remain cleanup-only and are omitted from
the published hydration snapshot. IDB and sessionStorage retirement markers also suppress every
owned pointer, domain row, quarantine row, and hydration issue before publication, including
invalid rows whose ownership is recovered from physical keys. The Firestore sentinel is
operational UI state, not a world/game projection.
- Remote-owned state: Jotai is an optimistic projection, never the authority. A local command is
Zod-parsed and reduced, committed atomically to the IDB outbox/projection checkpoint, and only then
published to Jotai. The sync layer delivers it to Firestore; exact receipts/snapshots are committed
to IDB before the reconciliation action republishes the view.
- Remote ingress is strict: Firestore snapshot → Zod parse → IDB transaction → Jotai
reconciliation. Do not set a game atom directly from an unvalidated listener payload.
- Exact invite hydration obeys that same order. An invite route asks the repository/application
facade to read one Game, parse it, and commit the lobby replica/checkpoint; only the post-commit
durable projection may enter Jotai.
- A Jotai write atom may issue a command to the IDB-owned persistence boundary, but components and
atom reducers do not call Firestore directly. Components and routes call command/hydration
facades and consume atoms; they never import the Firebase SDK.
- A create-game or join command may be durable in a prospective Game namespace while the confirmed
PlayerSession still belongs to the lobby. The projection-free prospective sender may wake for
that outbox, but Jotai must keep the confirmed lobby projection until the remote PlayerSession
snapshot establishes membership and the host hands off to the confirmed Game lane.
- Pillar 2 (Zod-first types) means: every
atomWithIDB takes a Zod schema; on set, the factory validates with schema.parse (zod-owned authoring) and only then writes through to IDB. Atom value type is z.infer<typeof Schema> — never hand-written.
- Pillar 4 (CLI-gate-first) means: a Zod parse failure on atom set throws and surfaces in the dev console — fix it like any other gate failure. The atom reducer's pure logic is unit-tested in
bun test; a real Provider + a real component subscribing is Playwright (Wave 4).
- React Compiler purity means: Jotai hooks are pure subscribers — safe to call directly in render with no
useMemo/useCallback wrapping (compiler handles memoization).
- Ephemeral UI state (focus, hover, transient toggles) stays in plain
useState — atomWithIDB is for things the player would notice losing.
Patterns
Remote-first choreography and authority
remote: Firestore snapshot -> Zod -> IDB replica -> Jotai reconcile -> React
local: user command -> Zod -> IDB outbox + projection checkpoint -> Jotai optimistic projection
-> Firestore write -> exact server receipt/snapshot -> IDB -> Jotai reconcile
The optimistic projection carries pending command IDs so reconciliation can distinguish confirmed,
rebased, and rejected work. The same pure reducer must be usable for the initial optimistic change
and replay after an authoritative snapshot. Jotai state alone is never delivery evidence.
The one exception to ordinary optimistic publication is Game establishment. Create/join work is
persisted before send, but its prospective Game namespace is transport-only and never enters
remoteProjectionAtom. A confirmed PlayerSession currentGameId is the only signal that changes the
active pointer and permits the normal Game projection to publish. Rejection therefore leaves the
confirmed lobby view intact instead of briefly rendering a Game the player never joined.
The generic Firebase and local-state convention is implemented, while production domain sync stays
disabled. connectionChecks/web remains telemetry; it is never projected into game atoms. App Check
and a trusted competitive reducer are not implied.
The current room projection is bounded to eight seats: two performers and at most six audience.
Open-game derivation excludes a pending Game at its authoritative pendingEndsAt, exactly 60
seconds after accepted creation, regardless of eventual TTL deletion. First arrival derives a
cached PLAYER.##### identity from the UUID PlayerSession through the session runtime. Active
domain records carry non-null, pre-scheduled purgeAt; Jotai still derives activity exclusively
from logical state/deadlines and accepts a terminal snapshot that resets cleanup eligibility to
exactly one hour after the logical end.
Durable-first command atom — optimistic projection, not transport
import { atom } from "jotai";
export const worldProjectionAtom = atom(initialWorldProjection);
export const issueWorldCommandAtom = atom(null, async (get, set, rawCommand: unknown) => {
const command = WorldCommandSchema.parse(rawCommand);
const previous = get(worldProjectionAtom);
const optimistic = reduceWorld(previous, command);
await persistReplicaAndEnqueue(previous, optimistic, command);
set(worldProjectionAtom, markPending(optimistic, command.id));
});
persistReplicaAndEnqueue is IDB-owned and commits the command/checkpoint atomically before Jotai is
changed. A separate sync loop sends the command. When a server-confirmed receipt/snapshot has been
validated and committed to IDB, an explicit Jotai write action reconciles the projection. Never hide
that sequence inside a render or transport callback.
Define an atom at module scope (never in render)
import { atom } from "jotai";
export const transientToggleAtom = atom(false);
export const doubledAtom = atom((get) => get(transientToggleAtom) ? 2 : 1);
Atoms in render would create a new identity every commit and loop. Always module-scope.
Read-only via useAtomValue; write-only via useSetAtom
import { useAtomValue, useSetAtom } from "jotai";
import { progressAtom } from "~/state/atoms";
function Score() {
const progress = useAtomValue(progressAtom);
return <span>{progress.score}</span>;
}
function NextLevelButton() {
const setProgress = useSetAtom(progressAtom);
return <button onClick={() => setProgress((p) => ({ ...p, score: p.score + 1 }))}>Score</button>;
}
useAtom only when the same component genuinely needs both. The split is what keeps re-renders surgical.
Current local-only atomWithIDB(schema, key, default) persistence factory
import { atom, type WritableAtom } from "jotai";
import type { z, ZodType } from "zod";
import { persist, readSync } from "~/state/persist";
import type { idbHydrationPromise } from "~/state/hydration";
type Hydrated = Awaited<typeof idbHydrationPromise>;
export function atomWithIDB<S extends ZodType>(
schema: S,
store: keyof Hydrated,
key: string,
fallback: z.infer<S>,
): WritableAtom<z.infer<S>, [z.infer<S> | ((prev: z.infer<S>) => z.infer<S>)], void> {
const initial = readSync(store, key) ?? fallback;
const valueAtom = atom<z.infer<S>>(initial);
return atom(
(get) => get(valueAtom),
(get, set, update) => {
const next = typeof update === "function"
? (update as (p: z.infer<S>) => z.infer<S>)(get(valueAtom))
: update;
const parsed = schema.parse(next);
set(valueAtom, parsed);
void persist(store, key, parsed);
},
);
}
The factory parses on set, writes through to IDB, and broadcasts to other tabs. It describes the
existing IDB-local seed state. Do not reuse this direct debounced write as the remote-command
outbox; remote-owned changes need atomic replica + command persistence and explicit reconciliation.
Root <Suspense> + use(idbHydrationPromise) — the only suspense for hydration
import { Suspense, use } from "react";
import { Provider } from "jotai";
import { idbHydrationPromise } from "~/state/hydration";
function Hydrated({ children }: { children: React.ReactNode }) {
use(idbHydrationPromise);
return <>{children}</>;
}
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<Provider>
<Suspense fallback={null /* the prerendered shell is the fallback */}>
<Hydrated>{children}</Hydrated>
</Suspense>
</Provider>
);
}
A single root suspense. After it resolves, current atomWithIDB atoms read their initial value
synchronously—no per-atom suspense or waterfalls. In the remote flow this is the last durable local
replica; a later Firestore snapshot reconciles it. The <Provider> lets tests and Storybook use a
fresh store.
Cross-tab re-hydration (Storybook iframe ↔ app, second window)
import { atom } from "jotai";
import { subscribeRemoteWrites } from "~/state/persist";
const versionAtom = atom(0);
if (typeof window !== "undefined") {
subscribeRemoteWrites(() => {
});
}
The BroadcastChannel itself lives in idb; Jotai subscribes and applies the committed local delta.
It is cross-tab invalidation, not a remote acknowledgement.
Derived atom — sync
import { atom } from "jotai";
import { progressAtom } from "./atoms";
export const completedCountAtom = atom((get) =>
Array.from(get(progressAtom).values()).filter((p) => p.completed).length,
);
Pure derivation; the React Compiler memoizes the consuming render — no useMemo.
Derived async atom — await get(...)
const resourceAtom = atom(async (_get, { signal }) => {
const res = await fetch("/data.json", { signal });
return ResourceSchema.parse(await res.json());
});
const resourceCountAtom = atom(async (get) => (await get(resourceAtom)).length);
In Jotai 2.x, get(asyncAtom) returns a Promise; you must await (or .then) it inside the read
function. Wrap a consuming component in <Suspense> if it reads the result via useAtomValue.
This pattern is for a validated read-only resource, not authoritative Firestore game state; that
state must flow through Zod and IDB before Jotai reconciliation.
Parameterized atoms — prefer the IDB key or selectAtom
For current local-only persistent per-id state, the key parameter on atomWithIDB already IS the
family — the IDB row is the cache, and a tiny module-scope Map<id, atom> memoizes the atom instance
so two components calling getProgressAtom(id) share subscription state. Bounded by IDB row count,
so no setShouldRemove policy is needed. Remote-owned collections also key the durable IDB
replica by ID, but mutations go through command/outbox atoms rather than this direct-write factory.
import { type GameProgress, GameProgressSchema } from "@snapmatch/schemas";
import type { WritableAtom } from "jotai";
import { atomWithIDB } from "~/lib/atom-with-idb";
import { persistProgress } from "./persist";
type ProgressAtom = WritableAtom<
GameProgress,
[GameProgress | ((prev: GameProgress) => GameProgress)],
void
>;
const progressAtoms = new Map<string, ProgressAtom>();
export function getProgressAtom(id: string): ProgressAtom {
let cached = progressAtoms.get(id);
if (!cached) {
cached = atomWithIDB(
GameProgressSchema,
(snapshot) => snapshot.progress.get(id),
persistProgress,
{ id, score: 0, round: 1, completed: false },
);
progressAtoms.set(id, cached);
}
return cached;
}
For derived per-id slices over a collection atom, use selectAtom from jotai/utils — it gives per-id subscription correctness (referential stability, equality fn) and ships with core jotai:
import { selectAtom } from "jotai/utils";
import { progressCollectionAtom } from "./atoms";
export const progressForIdAtom = (id: string) =>
selectAtom(progressCollectionAtom, (c) => c.get(id));
If neither pattern fits — rare for player-software — flag it for review before reaching for atomFamily / jotai-family. Snapmatch stack does not use atomFamily.
Provider + createStore for tests / Storybook isolation
import { createStore, Provider } from "jotai";
const store = createStore();
<Provider store={store}>{ui}</Provider>;
store.set(progressAtom, { level: 3, completed: true });
store.get(progressAtom);
Fresh store per story/test gives total isolation — the BroadcastChannel still fires, but a fresh IDB fixture (Playwright concern) keeps stories clean.
Anti-patterns
- Don't put durable or remote-projected state in
useState — the iPad-over-LAN reload erases it.
Use the atomWithIDB path for local-only state or a command/reconciliation atom backed by the IDB
replica/outbox for remote-owned state.
- Don't call Firestore directly from a component or optimistic atom reducer — enqueue a validated
command through the persistence boundary and let the sync layer deliver it.
- Don't apply an unvalidated Firestore payload to an atom — Zod and IDB commit precede Jotai
reconciliation.
- Don't publish a prospective Game namespace — create/join egress may send its durable outbox,
but only confirmed membership may replace the lobby projection.
- Don't treat
connectionChecks/web as application state — it is only the current connectivity
sentinel.
- Don't open a per-atom suspense for IDB hydration — the root
<Suspense> + use(idbHydrationPromise) runs once. Per-atom suspense breaks the synchronous-read guarantee and slows mount.
- Don't
useMemo/useCallback around Jotai hooks — React Compiler handles memoization; manual memo is noise that can mask purity bugs.
- Don't use
atomFamily at all in snapmatch — the IDB key + a module-scope Map<id, atom> is the family for persistent state; selectAtom(coll, c => c.get(id)) covers derived per-id slices. See the parameterized-atoms pattern above. The deprecated jotai/utils export and the jotai-family package are both excluded.
- Don't wrap
atomWithIDB in atomFamily — atomWithIDB already keys by id (the IDB row IS the family). Wrapping it in another family creates a second cache layer that disagrees with IDB, leaks unbounded, and breaks the synchronous-read guarantee on remount. Use getProgressAtom(id) (Map memoization) instead.
- Don't import
loadable from jotai/utils — deprecated since 2.17. Use unwrap for the rare case where a non-Suspense fallback is needed.
- Don't define atoms inside a component — module scope only. Render-defined atoms get a new identity every commit and loop.
- Don't write current local-only state through to IDB on every set without debouncing — the
factory's persist helper owns that policy. Remote commands instead require an atomic outbox write;
never debounce away a discrete command.
- Don't validate atom values with hand-written checks — every
atomWithIDB runs schema.parse on set; the schema is the contract (see zod).
Triggers on
atom, atomWithIDB, derived atom, jotai, jotai Provider, jotai store, parameterized atom, selectAtom, useAtom, useAtomValue, useSetAtom