@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 +274 -43
- package/dist/index.d.mts +556 -59
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1090 -475
- 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)
|
|
@@ -188,22 +220,33 @@ function App() {
|
|
|
188
220
|
|
|
189
221
|
```tsx
|
|
190
222
|
// ProjectEditor.tsx
|
|
191
|
-
import {
|
|
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.
|
|
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.
|
|
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
|
-
{
|
|
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).
|
|
398
|
-
|
|
399
|
-
|
|
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,
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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 `
|
|
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
|
|
850
|
-
//
|
|
851
|
-
if (
|
|
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
|
-
|
|
917
|
-
isSynced: true,
|
|
1148
|
+
isLoaded: true,
|
|
918
1149
|
sync: vi.fn(),
|
|
919
1150
|
error: undefined,
|
|
920
1151
|
ref: {},
|