@hvakr/firestate 0.1.2 → 0.1.3

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)
@@ -394,9 +426,11 @@ function App() {
394
426
 
395
427
  ### Pending edits on unmount
396
428
 
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:
429
+ Writes are debounced by `autosave` (default 1000 ms). The subscription is
430
+ shared and ref-counted, so its state and autosave timer survive as long as any
431
+ hook is still reading the resource. Only when the **last** reader unmounts with
432
+ unflushed local edits are those edits dropped silently — the shared subscription
433
+ is torn down and its autosave timer cleared. To handle this:
400
434
 
401
435
  - **Block navigation** with `useUnsavedChangesBlocker` (shown above) so users
402
436
  can't navigate away while writes are pending.
@@ -417,14 +451,15 @@ awaiting writes is not feasible.
417
451
  Creates typed React hooks from a registry object. Each key becomes a hook named
418
452
  `use{CapitalizedKey}`.
419
453
 
454
+ Call it **once per resource** — a document or collection with its base hook and
455
+ its [slice-hooks](#named-slice-hooks-select) — in that resource's own module. See
456
+ [Organizing by resource](#organizing-by-resource) for why, and the one sharing
457
+ rule that comes with it.
458
+
420
459
  ```typescript
460
+ // firestore/spaces.ts
421
461
  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
- })
462
+ import { createFirestate, col } from '@hvakr/firestate'
428
463
 
429
464
  const SpaceSchema = z.object({
430
465
  name: z.string(),
@@ -432,23 +467,18 @@ const SpaceSchema = z.object({
432
467
  floor: z.number(),
433
468
  })
434
469
 
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
- }),
470
+ const spaces = col({
471
+ path: 'projects/{projectId}/spaces',
472
+ schema: SpaceSchema,
473
+ lazy: true,
445
474
  })
475
+
476
+ export const { useSpaces } = createFirestate({ spaces })
446
477
  ```
447
478
 
448
479
  Generated hooks require the params implied by the path template:
449
480
 
450
481
  ```tsx
451
- const project = useProject({ projectId })
452
482
  const spaces = useSpaces({ projectId })
453
483
  ```
454
484
 
@@ -490,6 +520,60 @@ col({
490
520
  Path placeholders must look like `{name}`. Empty param values throw at runtime
491
521
  when a path is resolved.
492
522
 
523
+ #### Named slice-hooks (`.select`)
524
+
525
+ A bare entry generates a full-handle hook. Call `.select(...)` on an entry to
526
+ derive a **named slice-hook** that shares the entry's schema and path — so the
527
+ schema is declared once and reused — and re-renders only when its slice changes.
528
+ Each derived hook is a flat sibling in the API, named by its registry key:
529
+
530
+ Define each `.select` slice in the **same** `createFirestate` call as its base
531
+ entry — one resource per module (see [Organizing by resource](#organizing-by-resource)):
532
+
533
+ ```typescript
534
+ // firestore/tasks.ts
535
+ const tasks = col({ path: 'projects/{projectId}/tasks', schema: TaskSchema })
536
+
537
+ export const { useTasks, useTaskIds, useTaskById } = createFirestate({
538
+ tasks, // → useTasks (full handle)
539
+ taskIds: tasks.select((s) => Object.keys(s.data), { isEqual: shallow }),
540
+ // Parameterized: the selector declares its own params; the generated
541
+ // hook then requires them alongside the path params, in one bag.
542
+ taskById: tasks.select((s, p: { id: string }) => s.data[p.id]),
543
+ })
544
+
545
+ // firestore/project.ts — a separate resource, its own call:
546
+ // export const { useProject, useProjectTitle } = createFirestate({
547
+ // project,
548
+ // projectTitle: project.select((s) => s.data?.name),
549
+ // })
550
+ ```
551
+
552
+ The selector receives the same full observable state as the inline `selector`
553
+ option (see [Selecting a slice](#selecting-a-slice-selector--isequal)) and, for a
554
+ parameterized slice, the merged params bag. At the call site you pass that one
555
+ bag and (optionally) the runtime options — the selector and `isEqual` are baked
556
+ into the hook, never passed per call:
557
+
558
+ ```tsx
559
+ const ids = useTaskIds({ projectId }) // data: string[]
560
+ const one = useTaskById({ projectId, id }) // data: Task | undefined (merged bag)
561
+ const disabled = useTaskById({ projectId, id }, { enabled: false })
562
+ ```
563
+
564
+ A slice-hook returns a selected handle: `data` is the slice, plus the full
565
+ writer surface and `ref` — so `one.update({ ... })` still writes the whole task,
566
+ and a document slice-hook's `set` still replaces the whole document. Status
567
+ fields are not on it unless the slice reads them.
568
+
569
+ A base hook and all its slice-hooks share **one** subscription (one
570
+ `onSnapshot` listener, one optimistic state), so a write through any of them is
571
+ instantly visible to the rest — which is why they must live in one
572
+ `createFirestate` call. The inline `selector` option on the base hook still works
573
+ for one-off slices; reach for `.select` when a slice is named, reused, or
574
+ parameterized. (A derived entry is a leaf — there is no `.select(...).select(...)`
575
+ chaining.)
576
+
493
577
  ### Definition Helpers
494
578
 
495
579
  #### `defineDocument(definition)`
@@ -602,6 +686,84 @@ const id = add({ name: 'New Space', area: 500, floor: 1 })
602
686
  remove('oldSpaceId')
603
687
  ```
604
688
 
689
+ #### Selecting a slice (`selector` + `isEqual`)
690
+
691
+ By default a component re-renders whenever *any* field — or any status flag — of
692
+ the subscribed document or collection changes, and the hook returns the **full
693
+ handle** (`data`, `isLoading`, `isSynced`, `error`, the writers, and `ref`). Pass
694
+ a `selector` to take control: it receives the resource's full observable state
695
+ and returns the slice the component reacts to, so the component re-renders
696
+ **only** when that slice changes.
697
+
698
+ A selected handle exposes exactly your slice as `data`, plus the writer surface
699
+ (`update`/`set`/`delete`/`add`/`remove`/`load`/`sync`) and `ref` — the status
700
+ flags are **not** on it. You react to precisely what you select; status is not a
701
+ freebie, so read it from the state inside the selector when you need it. A
702
+ selector changes what you *read*, never what you *write*.
703
+
704
+ ```typescript
705
+ // Re-renders only when the title changes — not on any other field, and not on a
706
+ // save (isSynced) flip, because the selector never reads it.
707
+ const { data: title, update } = useDocument({
708
+ definition: projectDoc,
709
+ params: { projectId },
710
+ selector: (s) => s.data?.title,
711
+ })
712
+ update({ description: 'edited' }) // still a full-document update
713
+
714
+ // Need a status flag? Select it — then, and only then, you re-render on it.
715
+ const { data } = useDocument({
716
+ definition: projectDoc,
717
+ params: { projectId },
718
+ selector: (s) => ({ title: s.data?.title, saving: !s.isSynced }),
719
+ })
720
+
721
+ // On a collection, sub-select a single document or a derived value.
722
+ const { data: space } = useCollection({
723
+ definition: spacesCollection,
724
+ params: { projectId },
725
+ selector: (s) => s.data[spaceId],
726
+ })
727
+ ```
728
+
729
+ `s.data` is `undefined` while a document is loading (and the collection record is
730
+ `{}`), so selectors should handle the empty case.
731
+
732
+ When writing from a narrowed handle, use `update` — it takes a *partial* and
733
+ merges, so a selected field is just `update({ field: next })`. `set` still
734
+ **replaces the entire document**, not the slice: never pass the selected value
735
+ to `set` (e.g. `set(title)`) or you will overwrite every other field. Reach for
736
+ `set` only when you hold the full document.
737
+
738
+ By default the slice is compared with a deep value comparison, so a selector
739
+ that returns a fresh object/array of the same shape does **not** over-render.
740
+ Pass `isEqual` to tune it — `shallow` (exported) is a one-level compare for flat
741
+ projections:
742
+
743
+ ```typescript
744
+ import { shallow } from '@hvakr/firestate'
745
+
746
+ const { data: ids } = useCollection({
747
+ definition: spacesCollection,
748
+ params: { projectId },
749
+ selector: (s) => Object.keys(s.data),
750
+ isEqual: shallow,
751
+ })
752
+ ```
753
+
754
+ Selectors do not need to be memoized; an inline selector is recomputed each
755
+ render but only triggers a re-render when its result changes per `isEqual`.
756
+
757
+ Selectors compose cleanly across components because subscriptions are shared:
758
+ every hook reading the same resource (same definition, path, and query) shares
759
+ one Firestore listener and one reconciled state, so many components each
760
+ selecting a different slice cost a single listener. A write through any handle
761
+ is observed by all of them. `readOnly` is a per-handle capability, not part of
762
+ that key — a read-only hook shares the same listener and optimistic state as a
763
+ writable hook on the same resource, so the common provider/leaf pattern (one
764
+ writable owner, many `readOnly: true` read-selectors) sees the writer's
765
+ optimistic edits live. Only the read-only handle's own writers are disabled.
766
+
605
767
  #### `useUndoManager()`
606
768
 
607
769
  Access the undo manager.