This file is the repo-level operating manual for coding agents. Read it before changing code.
Firestate is a TypeScript library for using Cloud Firestore from React with real-time listeners, optimistic local state, debounced writes, undo/redo, sync state tracking, and optional Zod validation.
The recommended application API is registry-based, scoped one
createFirestate call per resource (a document or collection), each in its
own module alongside its schema and slice-hooks:
// firestore/tasks.ts
import { z } from 'zod'
import { createFirestate, col } from '@hvakr/firestate'
const TaskSchema = z.object({ title: z.string(), completed: z.boolean() })
const tasks = col({ path: 'taskLists/{listId}/tasks', schema: TaskSchema })
export const { useTasks, useTasksSyncStatus, useTaskById } = createFirestate({
tasks, // → useTasks (sync-agnostic handle) + useTasksSyncStatus + useTasksLoadingStatus
taskById: tasks.select((s, p: { id: string }) => s.data[p.id]), // → useTaskById (slice; no status hooks)
})createFirestate is a per-resource hook factory, not an app-global registry:
sharing is keyed by definition identity, so a resource's base hook and all its
.select slices must go through the SAME call to share one subscription, while
separate resources are separate calls (and correctly independent). See the
shared-subscription contract below.
The lower-level API is defineDocument / defineCollection plus
useDocument / useCollection. Use it for custom path derivation, non-React
usage, or plain TypeScript shapes without Zod validation.
Use pnpm.
pnpm install
pnpm typecheck
pnpm test
pnpm buildCI runs pnpm typecheck, pnpm build, and pnpm test on Node 22.
src/ is organized by layer: core/ (subscription engine + store), react/
(hooks + providers), registry/ (public registry API + definition helpers),
utils/ (framework-agnostic utilities). index.ts and types.ts stay at the
root. Tests live next to their source; cross-module integration tests and the
test harness live in src/__tests__/.
src/index.ts- public exports. Update this when adding public API.src/types.ts- public state, handle, definition, undo, and config types.src/registry/firestate.ts- registry API:createFirestate,doc,col, path template validation, generated hook typing.src/registry/schema.ts- lower-level definition helpers.src/react/hooks.ts- React hooks anduseSyncExternalStoreintegration.src/react/provider.tsx- React providers and unsaved-changes hook.src/core/store.ts- shared Firestore config, undo manager, global sync state, error reporting.src/core/document.ts- single-document subscription, optimistic state, set, update, delete, sync, conflict rebase.src/core/collection.ts- collection subscription, add, update, remove, batched sync, lazy loading.src/core/shared-subscription.ts- per-store, per-definition registry that ref-counts subscriptions keyed by(path, doc id / query)so every hook on the same resource shares one listener and one state.readOnlyis a per-handle capability layered on top, not part of the key. The hooks resolve a shared instance through here instead of constructing their own.src/utils/diff.ts- Firestore-aware diff, flattening, cloning, equality helpers.src/utils/undo.ts- framework-agnostic undo manager.src/__tests__/test-harness.ts- deterministic Firestore mock for tests.examples/react-tasks/- runnable React + Firebase example.
Preserve these unless the task explicitly changes them.
- The registry API requires Zod schemas.
doc()andcol()infer data types fromschemaand infer hook params from{name}placeholders inpath. defineDocumentanddefineCollectionkeep the plain TypeScript escape hatch. Theirschemafield is optional.- Schemas are validation guards only. Firestate calls
schema.parse(...)on full writes (document.set,collection.add) but stores the caller's original object. Do not store the parsed result unless intentionally changing this contract. - Partial
update(diff)calls are not Zod-validated because diffs may include Firestore sentinels such asserverTimestamp(),arrayUnion(), ordeleteField(). - Document
update()requires existing current data. Useset()to create or replace a document. - Collection
add,update, andremoverequire the first snapshot. They bail before the initial snapshot to avoid clobbering unknown server fields. enabled: falseon hooks must not resolve paths or create subscriptions. It returns stable no-op handles.queryConstraintsare keyed by semantic query identity, not by array reference. Never hand-roll a deep compare ofQueryConstraintobjects — they are opaque.useCollectionbuilds the query and compares it with Firestore'squeryEqual, so a fresh array producing the same query does not rebuild the listener; only a real change to the query (orpath) does.readOnlyis not part of the listener key (see the shared-subscription contract below). Callers therefore do not need to memoizequeryConstraintsfor correctness.useSyncExternalStoresnapshots and handles must have stable identity between changes. Do not rebuild snapshots on everygetSnapshot()call.- A hook
selectorreceives the resource's full observable state (DocumentState/CollectionState—data,isLoading,isLoaded,isSynced,error, and a collection'sisActive) and returns the slice that drives re-renders; the hook gates purely on that slice (default value-basedvaluesEqualForNoOp, or a suppliedisEqual). A selected handle exposes ONLY that slice asdataplus the writer surface (update/set/delete/add/remove/load/sync) andref— status fields are absent unless the selector folds them in, so a status flip the selector ignores (e.g.isSyncedchurning on a save) cannot re-render it. Writers/refare read live from the subscription (the snapshot is the state; the handle is read separately), not from the memoized selection, so a rebuilt subscription always surfaces its own methods even when the selected slice is value-equal. - A hook called WITHOUT a selector returns the sync-agnostic default handle:
{ data, isLoaded, error, ...writers, ref }for a document, plusisActivefor a collection. It deliberately dropsisSynced(andisLoading, folded intoisLoaded=!isLoadingfor docs,isActive && !isLoadingfor cols), so the autosaveisSyncedflip cannot re-render a plain data reader — the common "just render the record" path is the cheap default.DocumentState/CollectionStatestill carry every raw flag for selectors. Save/load state is opt-in via the per-resource status hooks below. createFirestategenerates, for each base doc/col entryK,use{K}SyncStatus({ isSynced, isSaving }) anduse{K}LoadingStatus({ isLoading, isLoaded }) beside the data hook..select(derived) entries do NOT get status hooks — a slice's status is the resource's. The status hooks are thin readers overuseDocument/useCollectionwith a fixed read-only selector, so they resolve the SAME shared entry (no extra listener) and read the same optimistic state; collection status hooks takequeryConstraintsand must match the data hook's query to share its listener. The provider-scoped aggregateuseIsSynced()is unchanged and orthogonal (all resources at once).- A registry entry's
.select(selector, { isEqual? })derives a named slice-hook that shares the entry's schema/path (declared once) and becomes a flat sibling in the generated API, named by its registry key. The selector is(state, params) => slice; its second arg declares the slice's own params (PExtra, default{}), and the generated hook's params are the path-template params intersected withPExtra(one merged bag —useTaskById({ projectId, id })).createFirestateadapts it to the inlineselectoroption by closing over the call's params bag, so a slice-hook is just a base hook with the selector/isEqualbaked in (call-site options carry onlyenabled/readOnly/queryConstraints). Derived entries are leaves: no.select(...).select(...). createFirestateis a per-resource hook factory, not an app-global registry. Each call builds its own definitions (memoized per base entry within that call), so a resource's base hook and all its.selectslices must go through the SAME call to share one subscription — splitting one resource across two calls forks it into two listeners with divergent optimistic state. The recommended layout is one resource (doc/col) per module with its owncreateFirestatecall and flat-exported hooks; separate resources are separate calls and are correctly independent. Docs and thereact-tasksexample follow this layout — keep new examples per-resource.- Subscriptions are shared and ref-counted, keyed by
(definition, resolved path, doc id / semantic query identity). EveryuseDocument/useCollectioncall for the same resource resolves the same underlying subscription throughsrc/core/shared-subscription.ts, so there is oneonSnapshotlistener and one reconciled/optimistic state no matter how many hooks (or selectors) read it — a write through any handle is instantly visible to all of them. The listener attaches on the firstload()and tears down (the underlyingstop()) only when the last subscriber unmounts; the entry is then evicted, so a later mount starts a fresh subscription (a lazy collection resets toisActive: false). Keying by definition object means two distinct definitions that resolve to the same path keep independent subscriptions. The lower-levelcreateDocumentSubscription/createCollectionSubscriptionremain unshared single instances for direct (non-React) use. readOnlyis a per-handle capability, NOT part of the share key. A writable hook (the typical provider — the sole writer) and any number ofreadOnly: truehooks (leaves that only read-select) on the same resource resolve the same entry and the same shared optimistic state. The shared subscription is always built writable; a read-only facade neuters only its own handle's writers (update/set/delete/add/remove) andsync(itsloadand reads pass through), and it does not touch the sharedundoableflag. A hook may passreadOnly: falseto opt back into writing a read-only-by-default definition without forking the shared state.- Undo recording is a property of the shared subscription, not the individual
hook: its
onPushUndopushes to the store-global undo manager gated by a sharedundoableflag that defaults tofalseand that co-mounted hooks keep in sync (last writer wins). Resources opt in with the hook'sundoable: trueoption. Per-callupdate(diff, { undoable: false })still suppresses a single entry. - Unmounting the last subscriber clears the shared autosave timer and
unregisters its sync state. Pending debounced edits are not automatically
flushed on
stop(). - Undo actions are client-local. Grouped undo actions undo newest to oldest and redo oldest to newest.
- Firestore updates use flattened diffs for
updateDoc; full document replacement and creation usesetDoc. - Pending
localStateis rebased onto every incoming snapshot, not only the one confirming an inflight write. The priorsyncStateis the baseline:localState = applyDiff(newSnapshot, computeDiff(baseline, localState)), then the baseline advances. Untouched fields follow the server; the client's own edits survive; same-field concurrent edits stay last-write-wins (local edit preserved and re-sent). Do not gate this rebase onwaitingForUpdate. - Collections enforce deletes-win: a doc in the baseline but absent from the
new snapshot was deleted remotely → drop it (and any local edits to it) and
never recreate it. A doc absent from the baseline but present locally is a
genuine create (
batch.set); an existing doc usesbatch.update.
Add or update focused tests near the behavior being changed:
- Registry typing/path behavior:
src/registry/schema.test.tsandsrc/registry/firestate.test.ts. - Document subscription behavior:
src/registry/firestate.test.tsandsrc/__tests__/firestate.integration.test.ts. - Collection behavior:
src/__tests__/firestate.integration.test.tsandsrc/core/store.test.ts. - Conflict/rebase and field-path behavior:
src/__tests__/conflict-resolution.test.ts,src/__tests__/reconcile.test.ts, andsrc/__tests__/fieldpath.test.ts. - Diff behavior:
src/utils/diff.test.ts. - Undo behavior:
src/utils/undo.test.ts. - Store/global sync behavior:
src/core/store.test.ts. - React hook surface — selectors, shared subscriptions,
queryConstraintsidentity:src/react/selectors.test.ts,src/react/shared-subscription.test.ts,src/react/hooks.test.ts. - Sync-agnostic default handle + per-resource status hooks
(
use{Name}SyncStatus/use{Name}LoadingStatus):src/react/status-hooks.test.ts.
Before finishing code changes, run at least:
pnpm typecheck
pnpm testFor public API or build output changes, also run:
pnpm build- User README:
README.md - Architecture notes:
docs/architecture.md - Usage recipes:
docs/api-recipes.md - Contributor workflow:
CONTRIBUTING.md
When changing public behavior, update the README or docs in the same change.