@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 +189 -27
- package/dist/index.d.mts +373 -31
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +952 -465
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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).
|
|
398
|
-
|
|
399
|
-
|
|
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,
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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.
|