@livequery/react 2.0.138 → 2.0.139

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
@@ -31,11 +31,18 @@ npm install @livequery/react @livequery/client react rxjs
31
31
  Create one `LivequeryClient` for your app or data boundary, provide it once, then use hooks inside descendant components.
32
32
 
33
33
  ```tsx
34
- import { LivequeryClient } from '@livequery/client'
34
+ import { LivequeryClient, LivequeryMemoryStorage } from '@livequery/client'
35
+ import { RestTransporter } from '@livequery/rest'
35
36
  import { LivequeryClientProvider } from '@livequery/react'
36
37
 
37
38
  const client = new LivequeryClient({
38
- endpoint: 'https://your-livequery-server'
39
+ storage: new LivequeryMemoryStorage(),
40
+ transporters: {
41
+ rest: new RestTransporter({
42
+ api: 'https://your-livequery-server',
43
+ ws: 'wss://your-livequery-server/ws',
44
+ }),
45
+ },
39
46
  })
40
47
 
41
48
  export function AppProviders({ children }: { children: React.ReactNode }) {
@@ -67,6 +74,10 @@ Use it when:
67
74
 
68
75
  The provider currently expects a `core` prop. Passing `client` will not work unless the implementation is changed.
69
76
 
77
+ ### SharedWorker
78
+
79
+ If your app uses a SharedWorker via `@livequery/rpc`, the setup inside the worker is different — but from React's perspective nothing changes. You still construct a `LivequeryClient` and pass it to `LivequeryClientProvider` exactly as shown above. Read the `@livequery/rpc` documentation for how to expose the client from a SharedWorker; the React layer stays the same.
80
+
70
81
  ## `useLivequeryClient`
71
82
 
72
83
  `useLivequeryClient()` reads the nearest `LivequeryClient` from `LivequeryClientProvider`.
@@ -133,30 +144,39 @@ Behavior notes:
133
144
 
134
145
  `useDocument<T>(ref, options)` is a document-focused convenience wrapper over `useCollection()`.
135
146
 
136
- It initializes a collection for a document ref, subscribes to collection items and loading state, then returns `[items[0], loading]`.
147
+ It initializes a collection for a document ref, subscribes to collection items, loading state, and error state, then returns `[items[0], loading, error]`.
137
148
 
138
- Use it when a component only needs one document and a loading flag.
149
+ Use it when a component only needs one document, a loading flag, and basic error handling.
139
150
 
140
151
  ```tsx
141
152
  import { useDocument } from '@livequery/react'
142
153
 
143
154
  type Todo = {
144
- _id: string
155
+ id: string
145
156
  title: string
146
157
  done: boolean
147
158
  }
148
159
 
149
160
  export function TodoDetail({ id }: { id: string }) {
150
- const [todo, loading] = useDocument<Todo>(`todos/${id}`)
161
+ const [todo, loading, error] = useDocument<Todo>(`todos/${id}`)
151
162
 
152
163
  if (loading) return <p>Loading...</p>
164
+ if (error) return <p>Error: {error.message}</p>
153
165
  if (!todo) return <p>Not found</p>
154
166
 
155
- return <h1>{todo.title}</h1>
167
+ return <h1>{todo.value.title}</h1>
156
168
  }
157
169
  ```
158
170
 
159
- Use `useCollection()` instead when you need collection methods, error state, multiple documents, or more control over subscriptions.
171
+ The return tuple:
172
+
173
+ | Index | Type | Value |
174
+ |---|---|---|
175
+ | `0` | `LivequeryDocument<DocState<T>> \| undefined` | The first document in the collection, or `undefined` when not yet loaded |
176
+ | `1` | `LivequeryLoadingState \| null` | Loading state: `null` when idle, `"all"` while the query is in flight |
177
+ | `2` | `{ code: string; message: string } \| null` | Error from the last query, or `null` when no error |
178
+
179
+ Use `useCollection()` instead when you need collection methods, multiple documents, or more control over subscriptions.
160
180
 
161
181
  ## `useObservable`
162
182
 
@@ -187,74 +207,178 @@ const lazyValue = useObservable(() => source$)
187
207
  Behavior notes:
188
208
 
189
209
  - `BehaviorSubject` is treated specially. Its initial value is read with `getValue()` so the first render can use the current value.
190
- - Lazy sources are resolved once for the hook lifetime.
210
+ - Lazy sources are resolved once for the hook lifetime. The source function is called only on the first render and is not re-called if the function reference changes later. If you need a different source, the component must remount.
191
211
  - If the source is `undefined`, the hook returns the default value, or `undefined` if no default was provided.
192
212
  - Reading `.value` or `.getValue()` manually in render is not a replacement for `useObservable()` because it will not subscribe the component to future emissions.
193
213
 
194
- ## Rendering a Collection (Mandatory Pattern)
214
+ ## 7 Rules for Using @livequery/react
215
+
216
+ These rules are mandatory. Breaking any one of them causes unnecessary re-renders, unhandled errors, or hard-to-debug state bugs.
217
+
218
+ ### Why two levels?
219
+
220
+ `collection.items` is a `BehaviorSubject<LivequeryDocument<T>[]>`.
221
+
222
+ - The **outer** `BehaviorSubject` emits a new array only when items are added, removed, or reordered.
223
+ - Each **element** is a `LivequeryDocument<T>` — itself a `BehaviorSubject<DocState<T>>` — that emits when that specific document's fields change.
224
+ - A field update on one document does **not** cause the outer array to emit. Only that document's own subject emits.
225
+
226
+ This means re-renders can be scoped to exactly the component that owns the changed data — but only if you follow the rules below.
227
+
228
+ ---
229
+
230
+ ### Rule 1 — `LivequeryClientProvider` is required
231
+
232
+ Every hook in this package reads the nearest `LivequeryClient` from context. There is no fallback. Using any hook outside a provider throws `Context provider is missing`.
233
+
234
+ ```tsx
235
+ // app root or route boundary
236
+ <LivequeryClientProvider core={client}>
237
+ <YourApp />
238
+ </LivequeryClientProvider>
239
+ ```
240
+
241
+ Create one client per data boundary, not one per component. The client holds the connection and cache — recreating it on every render loses all state.
242
+
243
+ ---
244
+
245
+ ### Rule 2 — SharedWorker: setup differs, React API stays the same
195
246
 
196
- `collection.items` is a `BehaviorSubject<BehaviorSubject<T>[]>`. This two-level structure is intentional and must be respected to achieve both realtime updates and high render performance.
247
+ If your app runs `@livequery/client` inside a SharedWorker via `@livequery/rpc`, the worker setup is different. From React's perspective nothing changes you still receive a `LivequeryClient` and pass it to `LivequeryClientProvider` exactly as normal. Read the `@livequery/rpc` docs for the worker side.
248
+
249
+ ---
250
+
251
+ ### Rule 3 — Place `useCollection` in the component that owns the list
252
+
253
+ `useCollection` belongs in the component that renders the list with `.map()`. Do not call it in a parent and pass the collection down as a prop — the collection is created once for that component's lifetime.
254
+
255
+ ```tsx
256
+ // ✓ correct — useCollection lives where the list is rendered
257
+ export function TodoList() {
258
+ const collection = useCollection<Todo>('todos', { lazy: false })
259
+ const items = useObservable(collection.items, [])
260
+ return <ul>{items.map(item => <TodoItem key={item.value.id} item={item} />)}</ul>
261
+ }
197
262
 
198
- **How it works:**
263
+ // wrong — collection created in parent, passed as prop
264
+ export function Parent() {
265
+ const collection = useCollection<Todo>('todos')
266
+ return <TodoList collection={collection} />
267
+ }
268
+ ```
199
269
 
200
- - The outer `BehaviorSubject` emits a new array only when items are added, removed, or reordered.
201
- - Each element in the array is itself a `BehaviorSubject<T>` that emits whenever that specific item's fields change.
202
- - A field update on one item emits only that item's inner subject — the outer array does not change and the parent list does not re-render.
270
+ ---
203
271
 
204
- This means correct rendering requires three separate component layers:
272
+ ### Rule 4 Unwrap `items` with `useObservable`
205
273
 
206
- ### Rule 1 Subscribe to the items array in the parent
274
+ `collection.items` is a `BehaviorSubject`. You must subscribe to it with `useObservable` to get the current array and re-render when items are added, removed, or reordered.
207
275
 
208
276
  ```tsx
209
277
  const items = useObservable(collection.items, [])
210
- // items = BehaviorSubject<T>[]
211
- // Re-renders only when item count or order changes
278
+ // items: LivequeryDocument<Todo>[]
279
+ // re-renders ONLY when count or order changes — not on field updates
212
280
  ```
213
281
 
214
- ### Rule 2 Render each item in its own component
282
+ Never read `collection.items.value` directly in render. It gives a snapshot that does not update.
283
+
284
+ ---
285
+
286
+ ### Rule 5 — Never call `.value` or `.getValue()` inside `.map()` — delegate to a child component
287
+
288
+ Each element of `items` is a `LivequeryDocument<T>` (a `BehaviorSubject`). Calling `.value` or `.getValue()` inside the parent map reads the value once — it does not subscribe, so field changes will not re-render.
215
289
 
216
- Pass the `BehaviorSubject<T>` as a prop and call `useObservable` inside the child. Field changes re-render only that child.
290
+ Pass the document to a child component and call `useObservable` inside:
217
291
 
218
292
  ```tsx
219
- function TodoItem({ item$ }: { item$: BehaviorSubject<Todo> }) {
220
- const item = useObservable(item$)
221
- return <li>{item.title}</li>
293
+ // wrong reads once, misses future field updates
294
+ {items.map(item => <li key={item.value.id}>{item.value.title}</li>)}
295
+
296
+ // ✗ also wrong — useObservable in parent map re-renders the whole list on any field change
297
+ {items.map(item => {
298
+ const todo = useObservable(item)
299
+ return <li key={todo.id}>{todo.title}</li>
300
+ })}
301
+
302
+ // ✓ correct — field updates re-render only TodoItem, not the list
303
+ {items.map(item => <TodoItem key={item.value.id} item={item} />)}
304
+
305
+ function TodoItem({ item }: { item: LivequeryDocument<Todo> }) {
306
+ const todo = useObservable(item) // subscribes inside the child
307
+ return <li>{todo.title}</li>
222
308
  }
223
309
  ```
224
310
 
225
- ### Rule 3 — Render loading state in its own component
311
+ ---
312
+
313
+ ### Rule 6 — `loading`, `paging`, and `summary` also belong in separate child components
226
314
 
227
- `collection.loading` is also a `BehaviorSubject<boolean>`. Place it in a separate component so toggling loading does not re-render the item list.
315
+ `collection.loading`, `collection.paging`, and `collection.summary` are all `BehaviorSubject`s. Calling `useObservable` on them in the same component as `items` means every loading toggle or paging update re-renders the entire list.
228
316
 
229
317
  ```tsx
230
- function TodoLoading({ loading$ }: { loading$: BehaviorSubject<boolean> }) {
318
+ // wrong — loading change re-renders the full list
319
+ export function TodoList() {
320
+ const collection = useCollection<Todo>('todos', { lazy: false })
321
+ const items = useObservable(collection.items, [])
322
+ const loading = useObservable(collection.loading) // ← causes full re-render on change
323
+ const paging = useObservable(collection.paging) // ← same
324
+ ...
325
+ }
326
+
327
+ // ✓ correct — each subject in its own component
328
+ function TodoLoading({ loading$ }: { loading$: LivequeryCollection<Todo>['loading'] }) {
231
329
  const loading = useObservable(loading$)
232
330
  if (!loading) return null
233
331
  return <p>Loading...</p>
234
332
  }
333
+
334
+ function TodoPaging({ paging$ }: { paging$: LivequeryCollection<Todo>['paging'] }) {
335
+ const paging = useObservable(paging$)
336
+ return <p>{paging.current} / {paging.total}</p>
337
+ }
338
+
339
+ function TodoSummary({ summary$ }: { summary$: LivequeryCollection<Todo>['summary'] }) {
340
+ const summary = useObservable(summary$)
341
+ return <p>Open: {summary.open}</p>
342
+ }
235
343
  ```
236
344
 
237
- ### Full example
345
+ ---
346
+
347
+ ### Full compliant example
238
348
 
239
349
  ```tsx
240
- import { BehaviorSubject } from 'rxjs'
350
+ import { LivequeryDocument } from '@livequery/client'
241
351
  import { useCollection, useObservable } from '@livequery/react'
242
352
 
243
- type Todo = { _id: string; title: string; done: boolean }
353
+ type Todo = { id: string; title: string; done: boolean }
244
354
 
245
- function TodoLoading({ loading$ }: { loading$: BehaviorSubject<boolean> }) {
355
+ function TodoItem({ item }: { item: LivequeryDocument<Todo> }) {
356
+ const todo = useObservable(item)
357
+ return (
358
+ <li>
359
+ <input
360
+ type="checkbox"
361
+ checked={todo.done}
362
+ onChange={() => item.update({ done: !todo.done })}
363
+ />
364
+ {todo.title}
365
+ {todo._updating && ' Saving…'}
366
+ </li>
367
+ )
368
+ }
369
+
370
+ function TodoLoading({ loading$ }: { loading$: LivequeryCollection<Todo>['loading'] }) {
246
371
  const loading = useObservable(loading$)
247
- if (!loading) return null
248
- return <p>Loading...</p>
372
+ return loading ? <p>Loading…</p> : null
249
373
  }
250
374
 
251
- function TodoItem({ item$ }: { item$: BehaviorSubject<Todo> }) {
252
- const item = useObservable(item$)
253
- return <li>{item.title}</li>
375
+ function TodoPaging({ paging$, onMore }: { paging$: LivequeryCollection<Todo>['paging'], onMore: () => void }) {
376
+ const paging = useObservable(paging$)
377
+ if (!paging.next) return null
378
+ return <button onClick={onMore}>Load more ({paging.total - paging.current} left)</button>
254
379
  }
255
380
 
256
381
  export function TodoList() {
257
- // lazy: false — no need to call collection.query() manually
258
382
  const collection = useCollection<Todo>('todos', { lazy: false })
259
383
  const items = useObservable(collection.items, [])
260
384
 
@@ -262,22 +386,126 @@ export function TodoList() {
262
386
  <>
263
387
  <TodoLoading loading$={collection.loading} />
264
388
  <ul>
265
- {items.map((item$) => (
266
- <TodoItem key={item$.getValue()._id} item$={item$} />
389
+ {items.map(item => (
390
+ <TodoItem key={item.value.id} item={item} />
267
391
  ))}
268
392
  </ul>
393
+ <TodoPaging paging$={collection.paging} onMore={() => collection.loadMore()} />
269
394
  </>
270
395
  )
271
396
  }
272
397
  ```
273
398
 
274
- **Why this matters:**
399
+ ---
400
+
401
+ ### Rule 7 — Wrap every action in `useAction`
402
+
403
+ Any call to `collection.add()`, `collection.update()`, `collection.delete()`, `collection.trigger()`, `item.update()`, `item.del()`, or `item.trigger()` is an async operation that can fail. Calling these directly in an event handler means:
404
+
405
+ - No loading state — UI has no way to show a spinner or disable the button
406
+ - No error state — a rejected promise becomes an unhandled exception that can crash the component
407
+ - Race conditions — two rapid clicks fire two concurrent calls with no guard
408
+
409
+ Wrap every action in `useAction` to get `loading`, `data`, and `error` as React state, with automatic race protection (only the latest call updates state).
410
+
411
+ `useAction` holds state (`loading`, `data`, `error`) that changes every time the action is called. Place it in the **same component as the button**, never in the component that owns `items`. If `useAction` lives in the list parent, every action call re-renders the entire list.
412
+
413
+ ```tsx
414
+ // ✗ wrong — unhandled rejection, no loading state, can crash
415
+ function AddButton({ collection }: { collection: LivequeryCollection<Todo> }) {
416
+ return (
417
+ <button onClick={() => collection.add({ title: 'New', done: false })}>
418
+ Add
419
+ </button>
420
+ )
421
+ }
422
+
423
+ // ✗ also wrong — useAction in the list parent re-renders the whole list on every call
424
+ function TodoList() {
425
+ const collection = useCollection<Todo>('todos', { lazy: false })
426
+ const add = useAction(() => collection.add({ title: 'New', done: false })) // ← wrong place
427
+ const items = useObservable(collection.items, [])
428
+ return (
429
+ <>
430
+ <button onClick={() => void add()}>Add</button>
431
+ <ul>{items.map(item => <TodoItem key={item.value.id} item={item} />)}</ul>
432
+ </>
433
+ )
434
+ }
435
+
436
+ // ✓ correct — useAction lives in its own button component, list never re-renders for it
437
+ function AddButton({ collection }: { collection: LivequeryCollection<Todo> }) {
438
+ const add = useAction(() => collection.add({ title: 'New', done: false }))
439
+
440
+ return (
441
+ <>
442
+ <button disabled={add.loading} onClick={() => void add()}>
443
+ {add.loading ? 'Adding…' : 'Add'}
444
+ </button>
445
+ {add.error && <p>Error: {add.error.message}</p>}
446
+ </>
447
+ )
448
+ }
449
+
450
+ function TodoList() {
451
+ const collection = useCollection<Todo>('todos', { lazy: false })
452
+ const items = useObservable(collection.items, [])
453
+ return (
454
+ <>
455
+ <AddButton collection={collection} />
456
+ <ul>{items.map(item => <TodoItem key={item.value.id} item={item} />)}</ul>
457
+ </>
458
+ )
459
+ }
460
+ ```
461
+
462
+ The same applies to document-level actions:
463
+
464
+ ```tsx
465
+ function TodoItem({ item }: { item: LivequeryDocument<Todo> }) {
466
+ const todo = useObservable(item)
467
+
468
+ const toggle = useAction(() => item.update({ done: !todo.done }))
469
+ const remove = useAction(() => item.del())
470
+ const archive = useAction(() => item.trigger('archive'))
471
+
472
+ return (
473
+ <li>
474
+ <input type="checkbox" checked={todo.done} disabled={toggle.loading} onChange={() => void toggle()} />
475
+ {todo.title}
476
+ <button disabled={remove.loading} onClick={() => void remove()}>
477
+ {remove.loading ? 'Deleting…' : 'Delete'}
478
+ </button>
479
+ {toggle.error && <span>Save failed: {toggle.error.code}</span>}
480
+ {remove.error && <span>Delete failed: {remove.error.code}</span>}
481
+ </li>
482
+ )
483
+ }
484
+ ```
485
+
486
+ And for collection triggers:
487
+
488
+ ```tsx
489
+ function ArchiveAllButton({ collection }: { collection: LivequeryCollection<Todo> }) {
490
+ const archive = useAction(
491
+ () => collection.trigger<{ count: number }>('archive-done'),
492
+ { onError: (e) => console.error('Archive failed', e) }
493
+ )
494
+
495
+ return (
496
+ <>
497
+ <button disabled={archive.loading} onClick={() => void archive()}>
498
+ {archive.loading ? 'Archiving…' : 'Archive done'}
499
+ </button>
500
+ {archive.data && <p>Archived {archive.data.count} items</p>}
501
+ </>
502
+ )
503
+ }
504
+ ```
275
505
 
276
- - Putting `useObservable(collection.loading)` and `useObservable(collection.items)` in the same parent component means every loading toggle re-renders the entire list, even when nothing changed.
277
- - Calling `useObservable(item$)` inside the parent map instead of a child component means every field change on any single item re-renders the whole list.
278
- - Following all three rules gives true per-item granularity: only the component that owns a changed field re-renders.
506
+ `useAction` accepts any async function, so it works for non-Livequery async operations too (form submissions, file uploads, etc.).
279
507
 
280
- > **Never** flatten `collection.items` by calling `useObservable` on each element inside the parent map. Always delegate to a child component.
508
+ ---
281
509
 
282
510
  ## `useAction`
283
511
 
@@ -351,14 +579,245 @@ Behavior notes:
351
579
 
352
580
  `LivequeryClientProvider` and `useLivequeryClient` are built with this helper.
353
581
 
582
+ ## `useCollection` vs `useDocument`
583
+
584
+ **Use `useCollection` when you need a list (plural).**
585
+ **Use `useDocument` when you need one item (singular).**
586
+
587
+ ```tsx
588
+ // list — collection ref has an odd number of segments
589
+ const collection = useCollection<Todo>('todos')
590
+ const collection = useCollection<Post>('users/u1/posts')
591
+
592
+ // single document — document ref has an even number of segments
593
+ const [todo, loading, error] = useDocument<Todo>('todos/todo-1')
594
+ const [post, loading, error] = useDocument<Post>('users/u1/posts/post-1')
595
+ ```
596
+
597
+ `useDocument` is a thin wrapper over `useCollection`. Under the hood it creates the same collection but exposes only `[firstItem, loading, error]`. Use `useCollection` when you need methods like `add`, `update`, `delete`, `sort`, or `loadMore`. Use `useDocument` when you only need to read or mutate one document through `item.update()` and `item.del()`.
598
+
599
+ ---
600
+
601
+ ## TypeScript
602
+
603
+ ### Define your document type with `Doc`
604
+
605
+ Import `Doc` from `@livequery/client` and extend it. Every document must have an `id: string` field — `Doc<T>` adds it automatically.
606
+
607
+ ```tsx
608
+ import type { Doc } from '@livequery/client'
609
+
610
+ type Todo = Doc<{
611
+ title: string
612
+ done: boolean
613
+ createdAt: number
614
+ }>
615
+
616
+ // use the type with hooks
617
+ const collection = useCollection<Todo>('todos')
618
+ const [todo] = useDocument<Todo>('todos/todo-1')
619
+ ```
620
+
621
+ ### Import types, not values
622
+
623
+ Only import types from `@livequery/client` in component files — the runtime objects (`LivequeryClient`, `LivequeryCollection`) live in your setup files, not in every component.
624
+
625
+ ```tsx
626
+ // ✓ correct — type-only imports in components
627
+ import type { Doc, DocState, LivequeryDocument, LivequeryCollection } from '@livequery/client'
628
+
629
+ // ✗ wrong — importing runtime values you don't construct here
630
+ import { LivequeryClient, LivequeryCollection } from '@livequery/client'
631
+ ```
632
+
633
+ ### Type props that receive collection or document
634
+
635
+ ```tsx
636
+ import type { LivequeryCollection, LivequeryDocument, Doc } from '@livequery/client'
637
+
638
+ type Todo = Doc<{ title: string; done: boolean }>
639
+
640
+ // list component receives a typed collection
641
+ function TodoList({ collection }: { collection: LivequeryCollection<Todo> }) { ... }
642
+
643
+ // item component receives a typed document subject
644
+ function TodoItem({ item }: { item: LivequeryDocument<Todo> }) { ... }
645
+ ```
646
+
647
+ ---
648
+
649
+ ## Document States
650
+
651
+ Every document in `items` is a `DocState<T>` which includes internal fields set by the client during optimistic mutations. Use these to show pending and error states in the UI.
652
+
653
+ | Field | When set | What to show |
654
+ |---|---|---|
655
+ | `_adding` | `local-first` add in progress | "Saving…", spinner, disable form |
656
+ | `_adding_error` | Server rejected the add | Error message, retry button |
657
+ | `_updating` | `local-first` update in progress | "Saving…", field disabled |
658
+ | `_updating_error` | Server rejected the update | Error message next to the field |
659
+ | `_deleting` | `local-first` delete in progress | Row dimmed, "Deleting…" |
660
+ | `_deleting_error` | Server rejected the delete | Error message, undo button |
661
+ | `_local_only` | Created with `local-only` mode | "Draft", "Unsaved" badge |
662
+
663
+ ```tsx
664
+ function TodoItem({ item }: { item: LivequeryDocument<Todo> }) {
665
+ const todo = useObservable(item)
666
+
667
+ const toggle = useAction(() => item.update({ done: !todo.done }))
668
+ const remove = useAction(() => item.del())
669
+
670
+ return (
671
+ <li style={{ opacity: todo._deleting ? 0.4 : 1 }}>
672
+ <input
673
+ type="checkbox"
674
+ checked={todo.done}
675
+ disabled={toggle.loading || !!todo._updating}
676
+ onChange={() => void toggle()}
677
+ />
678
+
679
+ <span>{todo.title}</span>
680
+
681
+ {todo._local_only && <span className="badge">Draft</span>}
682
+ {todo._updating && <span>Saving…</span>}
683
+ {todo._updating_error && <span>Save failed: {todo._updating_error.message}</span>}
684
+
685
+ <button disabled={remove.loading} onClick={() => void remove()}>
686
+ {todo._deleting ? 'Deleting…' : 'Delete'}
687
+ </button>
688
+ {todo._deleting_error && <span>Delete failed — {todo._deleting_error.message}</span>}
689
+ </li>
690
+ )
691
+ }
692
+ ```
693
+
694
+ Note: `_adding`, `_updating`, `_deleting` are set by `@livequery/client` during optimistic mutations. They are cleared automatically when the server responds. You do not need to manage them manually.
695
+
696
+ ---
697
+
698
+ ## Common Patterns
699
+
700
+ ### Conditional ref — wait for ID or auth before initializing
701
+
702
+ Pass a falsy value as `ref` to skip initialization. The collection stays empty with no loading state until `ref` becomes truthy.
703
+
704
+ ```tsx
705
+ function UserTodos({ userId }: { userId: string | null }) {
706
+ // does not initialize until userId is available
707
+ const collection = useCollection<Todo>(userId && `users/${userId}/todos`, { lazy: false })
708
+ const items = useObservable(collection.items, [])
709
+ return <ul>{items.map(item => <TodoItem key={item.value.id} item={item} />)}</ul>
710
+ }
711
+ ```
712
+
713
+ Accepted falsy values: `undefined`, `null`, `false`, `''`. Any of these skips `initialize()`.
714
+
715
+ ---
716
+
717
+ ### Search with debounce — filter without calling server on every keystroke
718
+
719
+ Create the collection with a `debounce` value (milliseconds), then call `debounceQuery` on every input change. The actual query fires only after the user stops typing.
720
+
721
+ ```tsx
722
+ function TodoSearch() {
723
+ const collection = useCollection<Todo>('todos', { debounce: 300 })
724
+ const items = useObservable(collection.items, [])
725
+
726
+ return (
727
+ <>
728
+ <input
729
+ placeholder="Search…"
730
+ onChange={e => collection.debounceQuery({ 'title:like': e.target.value })}
731
+ />
732
+ <ul>{items.map(item => <TodoItem key={item.value.id} item={item} />)}</ul>
733
+ </>
734
+ )
735
+ }
736
+ ```
737
+
738
+ `debounceQuery` does nothing unless the collection was created with `debounce: <ms>`.
739
+
740
+ ---
741
+
742
+ ### Pagination — load more / infinite scroll
743
+
744
+ Use `collection.paging` to check whether more pages are available, then call `loadMore()`. Put the button and paging state in their own component so page changes do not re-render the list.
745
+
746
+ ```tsx
747
+ function TodoPaging({ collection }: { collection: LivequeryCollection<Todo> }) {
748
+ const paging = useObservable(collection.paging)
749
+ const loadMore = useAction(() => collection.loadMore())
750
+
751
+ if (!paging?.next) return null
752
+
753
+ return (
754
+ <button disabled={loadMore.loading} onClick={() => void loadMore()}>
755
+ {loadMore.loading ? 'Loading…' : `Load more (${paging.total - paging.current} left)`}
756
+ </button>
757
+ )
758
+ }
759
+
760
+ function TodoList() {
761
+ const collection = useCollection<Todo>('todos', { lazy: false })
762
+ const items = useObservable(collection.items, [])
763
+
764
+ return (
765
+ <>
766
+ <ul>{items.map(item => <TodoItem key={item.value.id} item={item} />)}</ul>
767
+ <TodoPaging collection={collection} />
768
+ </>
769
+ )
770
+ }
771
+ ```
772
+
773
+ `loadMore()` appends results — it does not replace `items`. Use `loadPrev()` for the previous page.
774
+
775
+ ---
776
+
777
+ ### Mutations without querying (lazy collection)
778
+
779
+ Use `lazy: true` (the default) when you only need `add`, `update`, or `delete` and do not need the query results in this component. The collection is initialized but does not fire a query, so no network request is made and no items are loaded.
780
+
781
+ ```tsx
782
+ // A floating "Add" button that writes to a collection without reading it
783
+ function AddTodoButton() {
784
+ const collection = useCollection<Todo>('todos') // lazy: true by default — no query
785
+ const add = useAction(() => collection.add({ title: 'New todo', done: false }))
786
+
787
+ return (
788
+ <button disabled={add.loading} onClick={() => void add()}>
789
+ {add.loading ? 'Adding…' : 'Add Todo'}
790
+ </button>
791
+ )
792
+ }
793
+ ```
794
+
795
+ This avoids an unnecessary fetch. The collection is still connected to the client, so any local-only items you add will be visible to other collection instances on the same client that _do_ query the same ref.
796
+
797
+ > **Always wrap mutations in `useAction`** — it gives you `loading`, `error`, and race protection with no extra code.
798
+
799
+ ---
800
+
801
+ ### Automatic cleanup
802
+
803
+ `useCollection` registers a subscription with the client on mount and unsubscribes automatically on unmount. You do not need to write any cleanup code.
804
+
805
+ ```tsx
806
+ // this is enough — no useEffect cleanup needed
807
+ const collection = useCollection<Todo>('todos', { lazy: false })
808
+ // ↑ subscribes on mount, unsubscribes on unmount automatically
809
+ ```
810
+
811
+ ---
812
+
354
813
  ## Choosing The Right API
355
814
 
356
815
  - Use `LivequeryClientProvider` once near the app or data boundary.
357
816
  - Use `useLivequeryClient()` only when you need direct client access.
358
- - Use `useCollection()` when you need collection methods or multiple reactive collection fields.
359
- - Use `useDocument()` when you only need the first document and loading state.
817
+ - Use `useCollection()` when rendering a list or when you need collection methods.
818
+ - Use `useDocument()` when you need a single document it returns `[doc, loading, error]`.
360
819
  - Use `useObservable()` whenever an RxJS source should drive rendering.
361
- - Use `useAction()` for async event handlers that need loading, data, and error state.
820
+ - Use `useAction()` for every async action never call mutations directly in event handlers.
362
821
  - Use `createContextFromHook()` for package or app utilities that should expose provider plus hook pairs.
363
822
 
364
823
  ## Common Mistakes
@@ -367,7 +826,8 @@ Behavior notes:
367
826
  - Calling collection mutations directly during render.
368
827
  - Reading `BehaviorSubject` values manually and expecting rerenders.
369
828
  - Passing changing `useCollection()` options and expecting the existing collection instance to rebuild.
370
- - Using `useDocument()` when you need error state or collection methods.
829
+ - Using `useCollection()` when `useDocument()` is sufficient — `useDocument` now returns `[doc, loading, error]` and handles the common case.
830
+ - Using `useDocument()` when you need collection methods (`add`, `update`, `delete`, `sort`, `loadMore`). For mutations, use `useCollection()` and get the document from `collection.items.value[0]`.
371
831
  - Importing APIs not listed in the `Exports` section.
372
832
  - Calling `useObservable(item$)` for each item inside the parent `.map()` instead of delegating to a child component — this causes the entire list to re-render on every field change of any single item.
373
833
  - Observing `collection.loading` in the same component as the item list — loading state changes then re-render the full list.
@@ -1 +1 @@
1
- {"version":3,"file":"useAction.d.ts","sourceRoot":"","sources":["../src/useAction.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,SAAS,GAAI,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,EAAE,UAAS,OAAO,CAAC;IAAE,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAM;aAGxG,OAAO;WACT,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;YACrB;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE;CAoBhD,CAAA"}
1
+ {"version":3,"file":"useAction.d.ts","sourceRoot":"","sources":["../src/useAction.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,SAAS,GAAI,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,EAAE,UAAS,OAAO,CAAC;IAAE,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAM;aAGxG,OAAO;WACT,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;YACrB;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE;CAsBhD,CAAA"}
package/dist/useAction.js CHANGED
@@ -15,7 +15,9 @@ export const useAction = (fn, options = {}) => {
15
15
  catch (error) {
16
16
  options.onError?.(error);
17
17
  if (currentRequestId === requestId.current) {
18
- set_state({ loading: false, error: error instanceof Error ? { code: 'error', message: error.message } : { code: 'error', message: String(error) } });
18
+ const code = error?.code ?? 'error';
19
+ const message = error instanceof Error ? error.message : (error?.message ?? String(error));
20
+ set_state({ loading: false, error: { code, message } });
19
21
  }
20
22
  }
21
23
  };
@@ -1 +1 @@
1
- {"version":3,"file":"useAction.js","sourceRoot":"","sources":["../src/useAction.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAExC,MAAM,CAAC,MAAM,SAAS,GAAG,CAA6C,EAAK,EAAE,UAA0C,EAAE,EAAE,EAAE;IACzH,MAAM,SAAS,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;IAC3B,MAAM,CAAC,KAAK,EAAE,SAAS,CAAC,GAAG,QAAQ,CAIhC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAA;IAEtB,MAAM,CAAC,GAAG,KAAK,EAAE,GAAG,IAAS,EAAE,EAAE;QAC7B,MAAM,gBAAgB,GAAG,EAAE,SAAS,CAAC,OAAO,CAAA;QAC5C,SAAS,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QAC5B,IAAI,CAAC;YACD,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC,CAAA;YAC9B,IAAI,gBAAgB,KAAK,SAAS,CAAC,OAAO,EAAE,CAAC;gBACzC,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;YACvC,CAAC;YACD,OAAO,IAAI,CAAA;QACf,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACb,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,CAAA;YACxB,IAAI,gBAAgB,KAAK,SAAS,CAAC,OAAO,EAAE,CAAC;gBACzC,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,CAAA;YACxJ,CAAC;QACL,CAAC;IACL,CAAC,CAAA;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,CAAM,EAAE,KAAK,CAAC,CAAA;AACvC,CAAC,CAAA"}
1
+ {"version":3,"file":"useAction.js","sourceRoot":"","sources":["../src/useAction.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAExC,MAAM,CAAC,MAAM,SAAS,GAAG,CAA6C,EAAK,EAAE,UAA0C,EAAE,EAAE,EAAE;IACzH,MAAM,SAAS,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;IAC3B,MAAM,CAAC,KAAK,EAAE,SAAS,CAAC,GAAG,QAAQ,CAIhC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAA;IAEtB,MAAM,CAAC,GAAG,KAAK,EAAE,GAAG,IAAS,EAAE,EAAE;QAC7B,MAAM,gBAAgB,GAAG,EAAE,SAAS,CAAC,OAAO,CAAA;QAC5C,SAAS,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;QAC5B,IAAI,CAAC;YACD,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC,CAAA;YAC9B,IAAI,gBAAgB,KAAK,SAAS,CAAC,OAAO,EAAE,CAAC;gBACzC,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;YACvC,CAAC;YACD,OAAO,IAAI,CAAA;QACf,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACb,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,CAAA;YACxB,IAAI,gBAAgB,KAAK,SAAS,CAAC,OAAO,EAAE,CAAC;gBACzC,MAAM,IAAI,GAAI,KAAa,EAAE,IAAI,IAAI,OAAO,CAAA;gBAC5C,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAE,KAAa,EAAE,OAAO,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAA;gBACnG,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,CAAC,CAAA;YAC3D,CAAC;QACL,CAAC;IACL,CAAC,CAAA;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,CAAM,EAAE,KAAK,CAAC,CAAA;AACvC,CAAC,CAAA"}
@@ -1,5 +1,6 @@
1
- import { type Doc } from "@livequery/client";
2
- export declare const useDocument: <T extends Doc>(ref: string | undefined | "" | null | false, options?: {
3
- lazy?: boolean;
4
- }) => readonly [import("@livequery/client").LivequeryDocument<import("@livequery/client").DocState<T>> | undefined, import("@livequery/client").LivequeryLoadingState];
1
+ import { type Doc, type LivequeryCollectionOptions } from "@livequery/client";
2
+ export declare const useDocument: <T extends Doc>(ref: string | undefined | "" | null | false, options?: Pick<Partial<LivequeryCollectionOptions<T>>, "lazy" | "mode" | "seed">) => readonly [import("@livequery/client").LivequeryDocument<import("@livequery/client").DocState<T>> | undefined, import("@livequery/client").LivequeryLoadingState, {
3
+ code: string;
4
+ message: string;
5
+ } | null];
5
6
  //# sourceMappingURL=useDocument.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useDocument.d.ts","sourceRoot":"","sources":["../src/useDocument.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,GAAG,EAAE,MAAM,mBAAmB,CAAA;AAK5C,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,GAAG,EAAE,KAAK,MAAM,GAAG,SAAS,GAAG,EAAE,GAAG,IAAI,GAAG,KAAK,EAAE,UAAS;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,qKAKvH,CAAA"}
1
+ {"version":3,"file":"useDocument.d.ts","sourceRoot":"","sources":["../src/useDocument.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,GAAG,EAAE,KAAK,0BAA0B,EAAE,MAAM,mBAAmB,CAAA;AAK7E,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,GAAG,EAAE,KAAK,MAAM,GAAG,SAAS,GAAG,EAAE,GAAG,IAAI,GAAG,KAAK,EAAE,UAAS,IAAI,CAAC,OAAO,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAM;;;SAM3K,CAAA"}
@@ -2,9 +2,10 @@ import {} from "@livequery/client";
2
2
  import { useObservable } from "./useObservable.js";
3
3
  import { useCollection } from "./useCollection.js";
4
4
  export const useDocument = (ref, options = {}) => {
5
- const collection = useCollection(ref, { lazy: options.lazy });
5
+ const collection = useCollection(ref, options);
6
6
  const items = useObservable(collection.items);
7
7
  const loading = useObservable(collection.loading);
8
- return [items[0], loading];
8
+ const error = useObservable(collection.error);
9
+ return [items[0], loading, error];
9
10
  };
10
11
  //# sourceMappingURL=useDocument.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useDocument.js","sourceRoot":"","sources":["../src/useDocument.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,MAAM,mBAAmB,CAAA;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAGlD,MAAM,CAAC,MAAM,WAAW,GAAG,CAAgB,GAA2C,EAAE,UAA8B,EAAE,EAAE,EAAE;IACxH,MAAM,UAAU,GAAG,aAAa,CAAI,GAAG,EAAE,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,CAAA;IAChE,MAAM,KAAK,GAAG,aAAa,CAAC,UAAU,CAAC,KAAK,CAAC,CAAA;IAC7C,MAAM,OAAO,GAAG,aAAa,CAAC,UAAU,CAAC,OAAO,CAAC,CAAA;IACjD,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,CAAU,CAAA;AACvC,CAAC,CAAA"}
1
+ {"version":3,"file":"useDocument.js","sourceRoot":"","sources":["../src/useDocument.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6C,MAAM,mBAAmB,CAAA;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAGlD,MAAM,CAAC,MAAM,WAAW,GAAG,CAAgB,GAA2C,EAAE,UAAkF,EAAE,EAAE,EAAE;IAC5K,MAAM,UAAU,GAAG,aAAa,CAAI,GAAG,EAAE,OAAO,CAAC,CAAA;IACjD,MAAM,KAAK,GAAG,aAAa,CAAC,UAAU,CAAC,KAAK,CAAC,CAAA;IAC7C,MAAM,OAAO,GAAG,aAAa,CAAC,UAAU,CAAC,OAAO,CAAC,CAAA;IACjD,MAAM,KAAK,GAAG,aAAa,CAAC,UAAU,CAAC,KAAK,CAAC,CAAA;IAC7C,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAU,CAAA;AAC9C,CAAC,CAAA"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "url": "https://github.com/livequery/react"
5
5
  },
6
6
  "type": "module",
7
- "version": "2.0.138",
7
+ "version": "2.0.139",
8
8
  "description": "",
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
@@ -49,14 +49,14 @@
49
49
  "dist/**/*"
50
50
  ],
51
51
  "devDependencies": {
52
- "@livequery/client": "^2.0.136",
52
+ "@livequery/client": "^2.0.139",
53
53
  "@types/bun": "^1.3.14",
54
54
  "@types/react": "^19.2.14",
55
55
  "@types/react-test-renderer": "^19.1.0",
56
56
  "react-test-renderer": "^19.2.6"
57
57
  },
58
58
  "peerDependencies": {
59
- "@livequery/client": "^2.0.136",
59
+ "@livequery/client": "^2.0.139",
60
60
  "react": "^19.2.5",
61
61
  "rxjs": "^7.8.2"
62
62
  },