@hvakr/firestate 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,7 @@ Firestate provides a declarative, schema-first approach that eliminates boilerpl
21
21
 
22
22
  - **Zod schemas as the source of truth**: each document/collection is declared with a [Zod](https://zod.dev) schema; firestate infers the TypeScript type via `z.infer` and validates writes at runtime
23
23
  - **Real-time sync**: Automatic Firestore listeners with proper lifecycle management
24
+ - **Shared subscriptions**: Every hook reading the same resource shares one listener and one state, ref-counted across mounts — a write through any handle is instantly visible everywhere
24
25
  - **Optimistic updates**: Changes reflect immediately, sync in background
25
26
  - **Conflict resolution**: Automatic rebasing when concurrent changes occur
26
27
  - **Undo/redo**: Built-in command pattern with action grouping
@@ -31,24 +32,25 @@ Firestate provides a declarative, schema-first approach that eliminates boilerpl
31
32
 
32
33
  Firestate exposes two layers. Pick one based on what you're building:
33
34
 
34
- - **`createFirestate` + `doc` / `col`** (recommended for app code) — declare every Firestore thing in a single registry object; the library generates one typed React hook per entry. Each entry takes a `path` template and a Zod `schema`. In return you get:
35
+ - **`createFirestate` + `doc` / `col`** (recommended for app code) — declare a Firestore resource (a document or collection) with a `path` template and a Zod `schema`, and the library generates one typed React hook per entry. In return you get:
35
36
  - the data type (`TaskList`) inferred from the schema via `z.infer`
36
37
  - the param keys (`{ listId }`) inferred from the path template and enforced at call sites
37
38
  - runtime validation on `set` / `add` writes — bad data throws at the call site instead of after a Firestore round trip
38
39
 
39
40
  Partial `update(diff)` calls are intentionally NOT validated: diffs commonly include Firestore sentinels like `serverTimestamp()` that a strict schema would reject.
40
41
 
42
+ Treat `createFirestate` as a **per-resource hook factory**, not an app-wide registry: give each document/collection its own module (`firestore/taskList.ts`, `firestore/tasks.ts`, …) with one `createFirestate` call, and export the hooks flat. See [Organizing by resource](#organizing-by-resource).
43
+
41
44
  ```ts
45
+ // firestore/taskList.ts
42
46
  import { z } from 'zod'
43
- import { createFirestate, doc, col } from '@hvakr/firestate'
47
+ import { createFirestate, doc } from '@hvakr/firestate'
44
48
 
45
49
  const TaskListSchema = z.object({ name: z.string(), createdAt: z.number() })
46
- const TaskSchema = z.object({ title: z.string(), completed: z.boolean() })
47
50
 
48
- export const { useTaskList, useTasks } = createFirestate({
49
- taskList: doc({ path: 'taskLists/{listId}', schema: TaskListSchema }),
50
- tasks: col({ path: 'taskLists/{listId}/tasks', schema: TaskSchema }),
51
- })
51
+ const taskList = doc({ path: 'taskLists/{listId}', schema: TaskListSchema })
52
+
53
+ export const { useTaskList } = createFirestate({ taskList })
52
54
 
53
55
  // useTaskList({ listId }) — { listId: string } statically required
54
56
  // useTaskList() — type error: missing listId
@@ -63,9 +65,39 @@ Firestate exposes two layers. Pick one based on what you're building:
63
65
 
64
66
  Both layers share the same store, undo manager, and sync semantics — the registry is a thin layer on top of the lower-level primitives.
65
67
 
68
+ ### Organizing by resource
69
+
70
+ `createFirestate` is best used **once per resource**, not once for your whole app. Put each document or collection in its own module — its schema, its base hook, and its [named slice-hooks](#named-slice-hooks-select) together — and call `createFirestate` there:
71
+
72
+ ```ts
73
+ // firestore/tasks.ts
74
+ import { z } from 'zod'
75
+ import { createFirestate, col } from '@hvakr/firestate'
76
+
77
+ const TaskSchema = z.object({ title: z.string(), completed: z.boolean(), createdAt: z.number() })
78
+ const tasks = col({ path: 'taskLists/{listId}/tasks', schema: TaskSchema })
79
+
80
+ export const { useTasks, useTaskById } = createFirestate({
81
+ tasks, // → useTasks (full handle)
82
+ taskById: tasks.select((s, p: { id: string }) => s.data[p.id]),
83
+ })
84
+
85
+ // firestore/taskList.ts is a sibling module with its own createFirestate call.
86
+ // Components import directly from the resource: `import { useTaskById } from './firestore/tasks'`.
87
+ ```
88
+
89
+ Why per-resource rather than one central call:
90
+
91
+ - **It scales.** A central registry becomes a chokepoint every feature edits; resource modules keep a resource's schema, hooks, and slices colocated and let you code-split.
92
+ - **Sharing still works app-wide.** Subscriptions are keyed by *definition identity*, and a resource module's definition lives at module scope (one stable object), so every component using `useTasks`/`useTaskById` shares one `onSnapshot` listener and one optimistic state — no matter how many modules.
93
+ - **The store stays global.** All resource modules mount under one `FirestateProvider`, so undo/redo and sync tracking span every resource regardless of how you split the files.
94
+
95
+ **The one rule:** keep a resource's base hook *and* all its `.select` slices in the **same** `createFirestate` call. Each call builds its own definitions, so splitting one resource across two calls would fork it into two listeners. Separate *resources* in separate calls is exactly what you want; separating *one* resource is the mistake.
96
+
66
97
  ## Table of Contents
67
98
 
68
99
  - [Choosing an API](#choosing-an-api)
100
+ - [Organizing by resource](#organizing-by-resource)
69
101
  - [Installation](#installation)
70
102
  - [Quick Start](#quick-start)
71
103
  - [Examples](#examples)
@@ -188,22 +220,33 @@ function App() {
188
220
 
189
221
  ```tsx
190
222
  // ProjectEditor.tsx
191
- import { useDocument, useCollection, useUndoManager } from '@hvakr/firestate'
223
+ import {
224
+ useDocument,
225
+ useCollection,
226
+ useDocumentSyncStatus,
227
+ useUndoManager,
228
+ } from '@hvakr/firestate'
192
229
  import { projectDoc, spacesCollection } from './schemas'
193
230
 
194
231
  function ProjectEditor({ projectId }: { projectId: string }) {
195
232
  const params = { projectId }
196
233
 
197
- // Subscribe to project document
234
+ // Subscribe to project document. The default handle is sync-agnostic — it
235
+ // carries `data`/`isLoaded`/`error`, not `isSynced`, so it does NOT
236
+ // re-render when a save settles.
198
237
  const project = useDocument({ definition: projectDoc, params })
199
238
 
200
239
  // Subscribe to spaces collection (lazy)
201
240
  const spaces = useCollection({ definition: spacesCollection, params })
202
241
 
242
+ // Opt into save state only where you render it — shares the project's one
243
+ // listener, so it doesn't add a subscription.
244
+ const { isSaving } = useDocumentSyncStatus({ definition: projectDoc, params })
245
+
203
246
  // Access undo/redo
204
247
  const { undo, redo, canUndo, canRedo } = useUndoManager()
205
248
 
206
- if (project.isLoading) return <Spinner />
249
+ if (!project.isLoaded) return <Spinner />
207
250
  if (!project.data) return <NotFound />
208
251
 
209
252
  return (
@@ -221,7 +264,7 @@ function ProjectEditor({ projectId }: { projectId: string }) {
221
264
  {/* Lazy-load spaces */}
222
265
  {!spaces.isActive ? (
223
266
  <button onClick={spaces.load}>Load Spaces</button>
224
- ) : spaces.isLoading ? (
267
+ ) : !spaces.isLoaded ? (
225
268
  <Spinner />
226
269
  ) : (
227
270
  <ul>
@@ -234,7 +277,7 @@ function ProjectEditor({ projectId }: { projectId: string }) {
234
277
  )}
235
278
 
236
279
  {/* Sync indicator */}
237
- {!project.isSynced && <span>Saving...</span>}
280
+ {isSaving && <span>Saving...</span>}
238
281
  </div>
239
282
  )
240
283
  }
@@ -394,9 +437,11 @@ function App() {
394
437
 
395
438
  ### Pending edits on unmount
396
439
 
397
- Writes are debounced by `autosave` (default 1000 ms). If a component unmounts
398
- while there are unflushed local edits, those edits are dropped silently the
399
- subscription is gone and the autosave timer is cleared. To handle this:
440
+ Writes are debounced by `autosave` (default 1000 ms). The subscription is
441
+ shared and ref-counted, so its state and autosave timer survive as long as any
442
+ hook is still reading the resource. Only when the **last** reader unmounts with
443
+ unflushed local edits are those edits dropped silently — the shared subscription
444
+ is torn down and its autosave timer cleared. To handle this:
400
445
 
401
446
  - **Block navigation** with `useUnsavedChangesBlocker` (shown above) so users
402
447
  can't navigate away while writes are pending.
@@ -417,14 +462,15 @@ awaiting writes is not feasible.
417
462
  Creates typed React hooks from a registry object. Each key becomes a hook named
418
463
  `use{CapitalizedKey}`.
419
464
 
465
+ Call it **once per resource** — a document or collection with its base hook and
466
+ its [slice-hooks](#named-slice-hooks-select) — in that resource's own module. See
467
+ [Organizing by resource](#organizing-by-resource) for why, and the one sharing
468
+ rule that comes with it.
469
+
420
470
  ```typescript
471
+ // firestore/spaces.ts
421
472
  import { z } from 'zod'
422
- import { createFirestate, doc, col } from '@hvakr/firestate'
423
-
424
- const ProjectSchema = z.object({
425
- name: z.string(),
426
- createdAt: z.number(),
427
- })
473
+ import { createFirestate, col } from '@hvakr/firestate'
428
474
 
429
475
  const SpaceSchema = z.object({
430
476
  name: z.string(),
@@ -432,23 +478,18 @@ const SpaceSchema = z.object({
432
478
  floor: z.number(),
433
479
  })
434
480
 
435
- export const { useProject, useSpaces } = createFirestate({
436
- project: doc({
437
- path: 'projects/{projectId}',
438
- schema: ProjectSchema,
439
- }),
440
- spaces: col({
441
- path: 'projects/{projectId}/spaces',
442
- schema: SpaceSchema,
443
- lazy: true,
444
- }),
481
+ const spaces = col({
482
+ path: 'projects/{projectId}/spaces',
483
+ schema: SpaceSchema,
484
+ lazy: true,
445
485
  })
486
+
487
+ export const { useSpaces } = createFirestate({ spaces })
446
488
  ```
447
489
 
448
490
  Generated hooks require the params implied by the path template:
449
491
 
450
492
  ```tsx
451
- const project = useProject({ projectId })
452
493
  const spaces = useSpaces({ projectId })
453
494
  ```
454
495
 
@@ -490,6 +531,60 @@ col({
490
531
  Path placeholders must look like `{name}`. Empty param values throw at runtime
491
532
  when a path is resolved.
492
533
 
534
+ #### Named slice-hooks (`.select`)
535
+
536
+ A bare entry generates a full-handle hook. Call `.select(...)` on an entry to
537
+ derive a **named slice-hook** that shares the entry's schema and path — so the
538
+ schema is declared once and reused — and re-renders only when its slice changes.
539
+ Each derived hook is a flat sibling in the API, named by its registry key:
540
+
541
+ Define each `.select` slice in the **same** `createFirestate` call as its base
542
+ entry — one resource per module (see [Organizing by resource](#organizing-by-resource)):
543
+
544
+ ```typescript
545
+ // firestore/tasks.ts
546
+ const tasks = col({ path: 'projects/{projectId}/tasks', schema: TaskSchema })
547
+
548
+ export const { useTasks, useTaskIds, useTaskById } = createFirestate({
549
+ tasks, // → useTasks (full handle)
550
+ taskIds: tasks.select((s) => Object.keys(s.data), { isEqual: shallow }),
551
+ // Parameterized: the selector declares its own params; the generated
552
+ // hook then requires them alongside the path params, in one bag.
553
+ taskById: tasks.select((s, p: { id: string }) => s.data[p.id]),
554
+ })
555
+
556
+ // firestore/project.ts — a separate resource, its own call:
557
+ // export const { useProject, useProjectTitle } = createFirestate({
558
+ // project,
559
+ // projectTitle: project.select((s) => s.data?.name),
560
+ // })
561
+ ```
562
+
563
+ The selector receives the same full observable state as the inline `selector`
564
+ option (see [Selecting a slice](#selecting-a-slice-selector--isequal)) and, for a
565
+ parameterized slice, the merged params bag. At the call site you pass that one
566
+ bag and (optionally) the runtime options — the selector and `isEqual` are baked
567
+ into the hook, never passed per call:
568
+
569
+ ```tsx
570
+ const ids = useTaskIds({ projectId }) // data: string[]
571
+ const one = useTaskById({ projectId, id }) // data: Task | undefined (merged bag)
572
+ const disabled = useTaskById({ projectId, id }, { enabled: false })
573
+ ```
574
+
575
+ A slice-hook returns a selected handle: `data` is the slice, plus the full
576
+ writer surface and `ref` — so `one.update({ ... })` still writes the whole task,
577
+ and a document slice-hook's `set` still replaces the whole document. Status
578
+ fields are not on it unless the slice reads them.
579
+
580
+ A base hook and all its slice-hooks share **one** subscription (one
581
+ `onSnapshot` listener, one optimistic state), so a write through any of them is
582
+ instantly visible to the rest — which is why they must live in one
583
+ `createFirestate` call. The inline `selector` option on the base hook still works
584
+ for one-off slices; reach for `.select` when a slice is named, reused, or
585
+ parameterized. (A derived entry is a leaf — there is no `.select(...).select(...)`
586
+ chaining.)
587
+
493
588
  ### Definition Helpers
494
589
 
495
590
  #### `defineDocument(definition)`
@@ -538,8 +633,7 @@ const {
538
633
  update, // Update with partial diff
539
634
  set, // Replace entire document
540
635
  delete: del, // Delete the document
541
- isLoading, // Whether initial data is loading
542
- isSynced, // Whether all changes are synced
636
+ isLoaded, // Whether the initial snapshot has arrived (ready to render)
543
637
  sync, // Force sync immediately
544
638
  error, // Error from listener, if any
545
639
  ref, // Firestore DocumentReference
@@ -550,6 +644,12 @@ const {
550
644
  undoable: true, // Optional: enable undo (default: true)
551
645
  enabled: true, // Optional: set false until required params exist
552
646
  })
647
+
648
+ // The default handle is SYNC-AGNOSTIC: no `isSynced`, so a save settling does
649
+ // not re-render it. For save state, use the per-entry sync-status hook (with the
650
+ // registry API) or fold `isSynced` into a `selector`. `isLoading` likewise moved
651
+ // to the loading-status hook; the data handle keeps `isLoaded` for the common
652
+ // "spinner until ready" gate.
553
653
  ```
554
654
 
555
655
  #### `useCollection(options)`
@@ -562,9 +662,8 @@ const {
562
662
  update, // Update one or more documents
563
663
  add, // Add a new document (explicit or auto-generated id)
564
664
  remove, // Remove a document
565
- isLoading, // Whether initial data is loading
566
- isSynced, // Whether all changes are synced
567
- isActive, // Whether subscription is active
665
+ isLoaded, // Active AND past the initial load (isActive && !isLoading)
666
+ isActive, // Whether subscription is active (for lazy collections)
568
667
  load, // Activate a lazy subscription
569
668
  sync, // Force sync immediately
570
669
  error, // Error from listener, if any
@@ -602,6 +701,139 @@ const id = add({ name: 'New Space', area: 500, floor: 1 })
602
701
  remove('oldSpaceId')
603
702
  ```
604
703
 
704
+ #### Selecting a slice (`selector` + `isEqual`)
705
+
706
+ By default a component re-renders when the data, the load state (`isLoaded`), or
707
+ `error` of the subscribed document/collection changes, and the hook returns the
708
+ **sync-agnostic default handle** (`data`, `isLoaded`, `error`, the writers, and
709
+ `ref` — plus a collection's `isActive`). It deliberately omits `isSynced`, so a
710
+ save settling does not re-render it (see [Sync status and loading status](#sync-status-and-loading-status)).
711
+ Pass a `selector` to take further control: it receives the resource's *full*
712
+ observable state — including `isLoading`/`isSynced` — and returns the slice the
713
+ component reacts to, so the component re-renders **only** when that slice changes.
714
+
715
+ A selected handle exposes exactly your slice as `data`, plus the writer surface
716
+ (`update`/`set`/`delete`/`add`/`remove`/`load`/`sync`) and `ref` — the status
717
+ flags are **not** on it. You react to precisely what you select; status is not a
718
+ freebie, so read it from the state inside the selector when you need it. A
719
+ selector changes what you *read*, never what you *write*.
720
+
721
+ ```typescript
722
+ // Re-renders only when the title changes — not on any other field, and not on a
723
+ // save (isSynced) flip, because the selector never reads it.
724
+ const { data: title, update } = useDocument({
725
+ definition: projectDoc,
726
+ params: { projectId },
727
+ selector: (s) => s.data?.title,
728
+ })
729
+ update({ description: 'edited' }) // still a full-document update
730
+
731
+ // Need a status flag? Select it — then, and only then, you re-render on it.
732
+ const { data } = useDocument({
733
+ definition: projectDoc,
734
+ params: { projectId },
735
+ selector: (s) => ({ title: s.data?.title, saving: !s.isSynced }),
736
+ })
737
+
738
+ // On a collection, sub-select a single document or a derived value.
739
+ const { data: space } = useCollection({
740
+ definition: spacesCollection,
741
+ params: { projectId },
742
+ selector: (s) => s.data[spaceId],
743
+ })
744
+ ```
745
+
746
+ `s.data` is `undefined` while a document is loading (and the collection record is
747
+ `{}`), so selectors should handle the empty case.
748
+
749
+ When writing from a narrowed handle, use `update` — it takes a *partial* and
750
+ merges, so a selected field is just `update({ field: next })`. `set` still
751
+ **replaces the entire document**, not the slice: never pass the selected value
752
+ to `set` (e.g. `set(title)`) or you will overwrite every other field. Reach for
753
+ `set` only when you hold the full document.
754
+
755
+ By default the slice is compared with a deep value comparison, so a selector
756
+ that returns a fresh object/array of the same shape does **not** over-render.
757
+ Pass `isEqual` to tune it — `shallow` (exported) is a one-level compare for flat
758
+ projections:
759
+
760
+ ```typescript
761
+ import { shallow } from '@hvakr/firestate'
762
+
763
+ const { data: ids } = useCollection({
764
+ definition: spacesCollection,
765
+ params: { projectId },
766
+ selector: (s) => Object.keys(s.data),
767
+ isEqual: shallow,
768
+ })
769
+ ```
770
+
771
+ Selectors do not need to be memoized; an inline selector is recomputed each
772
+ render but only triggers a re-render when its result changes per `isEqual`.
773
+
774
+ Selectors compose cleanly across components because subscriptions are shared:
775
+ every hook reading the same resource (same definition, path, and query) shares
776
+ one Firestore listener and one reconciled state, so many components each
777
+ selecting a different slice cost a single listener. A write through any handle
778
+ is observed by all of them. `readOnly` is a per-handle capability, not part of
779
+ that key — a read-only hook shares the same listener and optimistic state as a
780
+ writable hook on the same resource, so the common provider/leaf pattern (one
781
+ writable owner, many `readOnly: true` read-selectors) sees the writer's
782
+ optimistic edits live. Only the read-only handle's own writers are disabled.
783
+
784
+ #### Sync status and loading status
785
+
786
+ The default data handle is **sync-agnostic**: it carries `data`/`isLoaded`/
787
+ `error` but never `isSynced`. That matters because `isSynced` flips on *every*
788
+ autosave settle — so if the data handle carried it, every component that merely
789
+ reads a record would re-render an extra time after each save. Most readers only
790
+ want the data; the few that render save state (a "Saving…" indicator, a
791
+ navigation blocker) opt in explicitly.
792
+
793
+ For each registry entry, `createFirestate` generates two opt-in status hooks
794
+ beside the data hook:
795
+
796
+ ```typescript
797
+ const { useSpaces, useSpacesSyncStatus, useSpacesLoadingStatus } =
798
+ createFirestate({ spaces: spacesEntry })
799
+
800
+ // Only this component re-renders when a save settles — not every data reader.
801
+ function SaveIndicator(params) {
802
+ const { isSynced, isSaving } = useSpacesSyncStatus(params)
803
+ return isSaving ? <Spinner /> : <Check />
804
+ }
805
+
806
+ // A spinner that shows load progress WITHOUT re-rendering when data changes.
807
+ function SpacesSpinner(params) {
808
+ const { isLoading, isLoaded } = useSpacesLoadingStatus(params)
809
+ return isLoading ? <Spinner /> : null
810
+ }
811
+ ```
812
+
813
+ Both share the entry's **one** `onSnapshot` listener with the data hook (and any
814
+ slice hooks) — sharing is keyed by `(definition, path, query)`, not by which
815
+ hook you call — so opting in costs no extra subscription. `useSpacesSyncStatus`
816
+ re-renders only when sync state flips; `useSpacesLoadingStatus` re-renders only
817
+ on the load transition, never on data. Collection status hooks take the same
818
+ `queryConstraints` as the data hook (pass the same query to share the listener).
819
+
820
+ On a **lazy** collection, a status hook does not call `load()` itself — so as
821
+ the *only* subscriber it stays idle (`{ isSynced: true, isSaving: false }` /
822
+ `{ isLoading: false, isLoaded: false }`) and attaches no listener. Pair it with
823
+ the data hook, whose `load()` activates the one shared listener the status hook
824
+ then rides. Non-lazy collections activate on mount, so this is lazy-only.
825
+
826
+ `.select` (slice) entries do **not** get their own status hooks — a slice's sync
827
+ and loading state is the resource's, read through the base entry's status hooks.
828
+
829
+ With the lower-level API there are standalone equivalents —
830
+ `useDocumentSyncStatus` / `useDocumentLoadingStatus` /
831
+ `useCollectionSyncStatus` / `useCollectionLoadingStatus`, each taking
832
+ `{ definition, params, enabled }` (collections also `queryConstraints`).
833
+
834
+ This is the per-resource counterpart to [`useIsSynced()`](#useissynced), which
835
+ reports a single provider-wide aggregate across *all* tracked resources.
836
+
605
837
  #### `useUndoManager()`
606
838
 
607
839
  Access the undo manager.
@@ -753,7 +985,7 @@ const combined = mergeDiffs(diff1, diff2)
753
985
  ## Notes
754
986
 
755
987
  - **`enabled` flag** — pass `enabled: false` to generated hooks or to `useDocument`/`useCollection` when route params or auth-derived ids are not ready yet. Disabled hooks do not resolve paths or attach listeners, which avoids building invalid Firestore paths like `projects//spaces`.
756
- - **Navigation flicker** — changing `params` rebuilds the listener and briefly shows `isLoading: true`. To keep the previous data visible across the transition, wrap your param in `useDeferredValue`.
988
+ - **Navigation flicker** — changing `params` rebuilds the listener and briefly shows the loading state (`isLoaded: false`). To keep the previous data visible across the transition, wrap your param in `useDeferredValue`.
757
989
  - **No cross-doc transactions** — writes are atomic per document and per collection (via `writeBatch`), but not across them. For now, use Firestore's `runTransaction` directly via `handle.ref`.
758
990
  - **Per-client undo** — `useUndoManager` is local; one user's undo doesn't propagate to others.
759
991
  - **Multi-tab sync** — handled automatically by Firestore's listeners; no extra setup.
@@ -846,9 +1078,9 @@ const project = useDocument({
846
1078
  params: { projectId: '123' },
847
1079
  })
848
1080
 
849
- // Missing documents are not errors — `data` is undefined and `isLoading`
850
- // is false. Render a create/empty state for that case.
851
- if (!project.isLoading && !project.data) {
1081
+ // Missing documents are not errors — once loaded, `data` is undefined.
1082
+ // Render a create/empty state for that case.
1083
+ if (project.isLoaded && !project.data) {
852
1084
  return <CreateProject />
853
1085
  }
854
1086
 
@@ -913,8 +1145,7 @@ vi.mock('@hvakr/firestate', () => ({
913
1145
  update: vi.fn(),
914
1146
  set: vi.fn(),
915
1147
  delete: vi.fn(),
916
- isLoading: false,
917
- isSynced: true,
1148
+ isLoaded: true,
918
1149
  sync: vi.fn(),
919
1150
  error: undefined,
920
1151
  ref: {},