query-optimistic 0.1.0 → 0.2.0

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
@@ -1,16 +1,64 @@
1
- # query-optimistic
2
-
3
- Simple, type-safe data fetching and optimistic updates for React.
4
-
5
- A lightweight wrapper around TanStack Query that provides a cleaner API for defining queries, mutations, and handling optimistic updates with full TypeScript inference.
1
+ <p align="center">
2
+ <img src="./assets/logo.svg" alt="query-optimistic" width="400" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <strong>Simple, type-safe data fetching and optimistic updates for React</strong>
7
+ </p>
8
+
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/query-optimistic"><img src="https://img.shields.io/npm/v/query-optimistic.svg?style=flat-square" alt="npm version" /></a>
11
+ <a href="https://www.npmjs.com/package/query-optimistic"><img src="https://img.shields.io/npm/dm/query-optimistic.svg?style=flat-square" alt="npm downloads" /></a>
12
+ <a href="https://bundlephobia.com/package/query-optimistic"><img src="https://img.shields.io/bundlephobia/minzip/query-optimistic?style=flat-square" alt="bundle size" /></a>
13
+ <a href="https://github.com/avkulpin/query-optimistic/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/query-optimistic.svg?style=flat-square" alt="license" /></a>
14
+ <a href="https://github.com/avkulpin/query-optimistic"><img src="https://img.shields.io/badge/TypeScript-Ready-blue?style=flat-square" alt="TypeScript" /></a>
15
+ </p>
16
+
17
+ <p align="center">
18
+ A lightweight wrapper around <a href="https://tanstack.com/query">TanStack Query</a> that provides a cleaner API for defining queries, mutations, and handling optimistic updates with full TypeScript inference.
19
+ </p>
20
+
21
+ ---
22
+
23
+ ## Why query-optimistic?
24
+
25
+ TanStack Query is powerful but optimistic updates can get complex fast. This library gives you:
26
+
27
+ | Feature | TanStack Query | query-optimistic |
28
+ |---------|---------------|------------------|
29
+ | Define data sources | Inline in each component | Once, reuse everywhere |
30
+ | Optimistic updates | Manual cache manipulation | Intuitive channel API |
31
+ | Type safety | Manual type annotations | Automatic inference |
32
+ | Multi-query updates | Complex cache logic | Simple method chaining |
33
+ | Rollback on error | Manual implementation | Automatic |
34
+
35
+ ```tsx
36
+ // Before: TanStack Query optimistic update
37
+ useMutation({
38
+ mutationFn: createTodo,
39
+ onMutate: async (newTodo) => {
40
+ await queryClient.cancelQueries({ queryKey: ['todos'] })
41
+ const previous = queryClient.getQueryData(['todos'])
42
+ queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
43
+ return { previous }
44
+ },
45
+ onError: (err, newTodo, context) => {
46
+ queryClient.setQueryData(['todos'], context.previous)
47
+ },
48
+ onSettled: () => {
49
+ queryClient.invalidateQueries({ queryKey: ['todos'] })
50
+ }
51
+ })
6
52
 
7
- ## Features
53
+ // After: query-optimistic
54
+ useMutation(createTodo, {
55
+ optimistic: (channel, todo) => {
56
+ channel(todosCollection).append(todo, { reconcile: true })
57
+ }
58
+ })
59
+ ```
8
60
 
9
- - **Type-safe definitions** - Define queries and mutations once, get full type inference everywhere
10
- - **Simplified optimistic updates** - Imperative channel API for intuitive UI updates
11
- - **Automatic rollback** - Failed mutations automatically revert optimistic changes
12
- - **Multiple query sync** - Update multiple queries/entities in a single mutation
13
- - **Framework agnostic core** - Core utilities work without React
61
+ ---
14
62
 
15
63
  ## Installation
16
64
 
@@ -18,217 +66,695 @@ A lightweight wrapper around TanStack Query that provides a cleaner API for defi
18
66
  npm install query-optimistic @tanstack/react-query
19
67
  ```
20
68
 
69
+ ```bash
70
+ yarn add query-optimistic @tanstack/react-query
71
+ ```
72
+
73
+ ```bash
74
+ pnpm add query-optimistic @tanstack/react-query
75
+ ```
76
+
77
+ ---
78
+
21
79
  ## Quick Start
22
80
 
81
+ ### 1. Define your data sources
82
+
23
83
  ```typescript
24
- import { defineCollection, defineMutation, useQuery, useMutation } from 'query-toolkit'
84
+ import { defineCollection, defineEntity, defineMutation } from 'query-optimistic'
25
85
 
26
- // Define a collection
27
- const usersCollection = defineCollection({
28
- name: 'users',
29
- id: (user) => user.id,
30
- fetch: () => api.get('/users')
86
+ // A collection is an array of items
87
+ const todosCollection = defineCollection({
88
+ name: 'todos',
89
+ id: (todo) => todo.id,
90
+ fetch: () => api.get('/todos')
31
91
  })
32
92
 
33
- // Define a mutation
34
- const createUser = defineMutation({
35
- mutate: (params: { name: string }) => api.post('/users', params)
93
+ // An entity is a single item
94
+ const userEntity = defineEntity({
95
+ name: 'currentUser',
96
+ fetch: () => api.get('/me')
36
97
  })
37
98
 
38
- // Use in components
39
- function UserList() {
40
- const [users, { isLoading }] = useQuery(usersCollection)
99
+ // Define mutations separately
100
+ const createTodo = defineMutation({
101
+ mutate: (params: { title: string }) => api.post('/todos', params)
102
+ })
103
+ ```
104
+
105
+ ### 2. Use in components
106
+
107
+ ```tsx
108
+ import { useQuery, useMutation } from 'query-optimistic'
41
109
 
42
- const { mutate } = useMutation(createUser, {
110
+ function TodoApp() {
111
+ const [todos, { isLoading }] = useQuery(todosCollection)
112
+ const [user] = useQuery(userEntity)
113
+
114
+ const { mutate: addTodo, isPending } = useMutation(createTodo, {
43
115
  optimistic: (channel, params) => {
44
- channel(usersCollection).prepend({
45
- id: 'temp-' + Date.now(),
46
- name: params.name,
47
- }, { sync: true })
116
+ channel(todosCollection).append({
117
+ id: `temp-${Date.now()}`,
118
+ title: params.title,
119
+ completed: false,
120
+ }, { reconcile: true })
48
121
  }
49
122
  })
50
123
 
124
+ if (isLoading) return <div>Loading...</div>
125
+
51
126
  return (
52
127
  <div>
53
- <button onClick={() => mutate({ name: 'New User' })}>Add User</button>
54
- {users?.map(user => <div key={user.id}>{user.name}</div>)}
128
+ <h1>Welcome, {user?.name}</h1>
129
+ <button
130
+ onClick={() => addTodo({ title: 'New task' })}
131
+ disabled={isPending}
132
+ >
133
+ Add Todo
134
+ </button>
135
+ <ul>
136
+ {todos?.map(todo => (
137
+ <li key={todo.id}>{todo.title}</li>
138
+ ))}
139
+ </ul>
55
140
  </div>
56
141
  )
57
142
  }
58
143
  ```
59
144
 
60
- ## Defining Data Sources
145
+ ---
61
146
 
62
- ### Collections (Arrays)
147
+ ## Examples
63
148
 
64
- ```typescript
65
- interface User {
149
+ ### Todo List with CRUD Operations
150
+
151
+ A complete todo list with create, update, toggle, and delete operations:
152
+
153
+ ```tsx
154
+ import { defineCollection, defineMutation, useQuery, useMutation } from 'query-optimistic'
155
+
156
+ interface Todo {
66
157
  id: string
67
- name: string
68
- email: string
158
+ title: string
159
+ completed: boolean
160
+ createdAt: string
69
161
  }
70
162
 
71
- const usersCollection = defineCollection<User, { page?: number }>({
72
- name: 'users',
73
- id: (user) => user.id, // Required: how to identify items
74
- fetch: ({ page = 1 }) => api.get(`/users?page=${page}`)
163
+ // Define the collection
164
+ const todosCollection = defineCollection<Todo>({
165
+ name: 'todos',
166
+ id: (todo) => todo.id,
167
+ fetch: () => fetch('/api/todos').then(r => r.json())
168
+ })
169
+
170
+ // Define mutations
171
+ const createTodo = defineMutation<{ title: string }, Todo>({
172
+ mutate: (params) => fetch('/api/todos', {
173
+ method: 'POST',
174
+ body: JSON.stringify(params)
175
+ }).then(r => r.json())
176
+ })
177
+
178
+ const toggleTodo = defineMutation<{ id: string; completed: boolean }, Todo>({
179
+ mutate: ({ id, completed }) => fetch(`/api/todos/${id}`, {
180
+ method: 'PATCH',
181
+ body: JSON.stringify({ completed })
182
+ }).then(r => r.json())
75
183
  })
184
+
185
+ const deleteTodo = defineMutation<{ id: string }, void>({
186
+ mutate: ({ id }) => fetch(`/api/todos/${id}`, { method: 'DELETE' })
187
+ })
188
+
189
+ // Component
190
+ function TodoList() {
191
+ const [todos, { isLoading }] = useQuery(todosCollection)
192
+
193
+ const { mutate: create } = useMutation(createTodo, {
194
+ optimistic: (channel, params) => {
195
+ channel(todosCollection).append({
196
+ id: `temp-${Date.now()}`,
197
+ title: params.title,
198
+ completed: false,
199
+ createdAt: new Date().toISOString()
200
+ }, { reconcile: true })
201
+ }
202
+ })
203
+
204
+ const { mutate: toggle } = useMutation(toggleTodo, {
205
+ optimistic: (channel, params) => {
206
+ channel(todosCollection).update(params.id, todo => ({
207
+ ...todo,
208
+ completed: params.completed
209
+ }))
210
+ }
211
+ })
212
+
213
+ const { mutate: remove } = useMutation(deleteTodo, {
214
+ optimistic: (channel, params) => {
215
+ channel(todosCollection).delete(params.id)
216
+ }
217
+ })
218
+
219
+ return (
220
+ <div>
221
+ <form onSubmit={(e) => {
222
+ e.preventDefault()
223
+ const input = e.currentTarget.elements.namedItem('title') as HTMLInputElement
224
+ create({ title: input.value })
225
+ input.value = ''
226
+ }}>
227
+ <input name="title" placeholder="What needs to be done?" />
228
+ <button type="submit">Add</button>
229
+ </form>
230
+
231
+ <ul>
232
+ {todos?.map(todo => (
233
+ <li
234
+ key={todo.id}
235
+ style={{ opacity: todo._optimistic?.status === 'pending' ? 0.6 : 1 }}
236
+ >
237
+ <input
238
+ type="checkbox"
239
+ checked={todo.completed}
240
+ onChange={() => toggle({ id: todo.id, completed: !todo.completed })}
241
+ />
242
+ <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
243
+ {todo.title}
244
+ </span>
245
+ <button onClick={() => remove({ id: todo.id })}>Delete</button>
246
+ </li>
247
+ ))}
248
+ </ul>
249
+ </div>
250
+ )
251
+ }
76
252
  ```
77
253
 
78
- ### Entities (Single Items)
254
+ ---
79
255
 
80
- ```typescript
81
- interface Profile {
256
+ ### Shopping Cart
257
+
258
+ Real-time cart updates with quantity management:
259
+
260
+ ```tsx
261
+ interface CartItem {
82
262
  id: string
263
+ productId: string
83
264
  name: string
84
- avatar: string
265
+ price: number
266
+ quantity: number
85
267
  }
86
268
 
87
- const profileEntity = defineEntity<Profile, string>({
88
- name: 'profile',
89
- fetch: (userId) => api.get(`/users/${userId}/profile`)
269
+ interface CartSummary {
270
+ itemCount: number
271
+ total: number
272
+ }
273
+
274
+ const cartCollection = defineCollection<CartItem>({
275
+ name: 'cart',
276
+ id: (item) => item.id,
277
+ fetch: () => api.get('/cart/items')
90
278
  })
279
+
280
+ const cartSummaryEntity = defineEntity<CartSummary>({
281
+ name: 'cartSummary',
282
+ fetch: () => api.get('/cart/summary')
283
+ })
284
+
285
+ const updateQuantity = defineMutation<{ id: string; quantity: number }, CartItem>({
286
+ mutate: ({ id, quantity }) => api.patch(`/cart/items/${id}`, { quantity })
287
+ })
288
+
289
+ const removeFromCart = defineMutation<{ id: string }, void>({
290
+ mutate: ({ id }) => api.delete(`/cart/items/${id}`)
291
+ })
292
+
293
+ function Cart() {
294
+ const [items] = useQuery(cartCollection)
295
+ const [summary] = useQuery(cartSummaryEntity)
296
+
297
+ const { mutate: updateQty } = useMutation(updateQuantity, {
298
+ optimistic: (channel, params) => {
299
+ // Update the item quantity
300
+ channel(cartCollection).update(params.id, item => ({
301
+ ...item,
302
+ quantity: params.quantity
303
+ }))
304
+
305
+ // Update the summary
306
+ channel(cartSummaryEntity).update(s => {
307
+ const item = items?.find(i => i.id === params.id)
308
+ const diff = params.quantity - (item?.quantity ?? 0)
309
+ return {
310
+ itemCount: s.itemCount + diff,
311
+ total: s.total + (item?.price ?? 0) * diff
312
+ }
313
+ })
314
+ }
315
+ })
316
+
317
+ const { mutate: remove } = useMutation(removeFromCart, {
318
+ optimistic: (channel, params) => {
319
+ const item = items?.find(i => i.id === params.id)
320
+
321
+ channel(cartCollection).delete(params.id)
322
+
323
+ channel(cartSummaryEntity).update(s => ({
324
+ itemCount: s.itemCount - (item?.quantity ?? 0),
325
+ total: s.total - (item?.price ?? 0) * (item?.quantity ?? 0)
326
+ }))
327
+ }
328
+ })
329
+
330
+ return (
331
+ <div>
332
+ <h2>Cart ({summary?.itemCount} items)</h2>
333
+ {items?.map(item => (
334
+ <div key={item.id}>
335
+ <span>{item.name}</span>
336
+ <select
337
+ value={item.quantity}
338
+ onChange={(e) => updateQty({ id: item.id, quantity: +e.target.value })}
339
+ >
340
+ {[1, 2, 3, 4, 5].map(n => <option key={n}>{n}</option>)}
341
+ </select>
342
+ <span>${(item.price * item.quantity).toFixed(2)}</span>
343
+ <button onClick={() => remove({ id: item.id })}>Remove</button>
344
+ </div>
345
+ ))}
346
+ <div><strong>Total: ${summary?.total.toFixed(2)}</strong></div>
347
+ </div>
348
+ )
349
+ }
91
350
  ```
92
351
 
93
- ### Mutations
352
+ ---
94
353
 
95
- ```typescript
96
- interface CreateUserParams {
97
- name: string
98
- email: string
354
+ ### Social Media Feed with Likes
355
+
356
+ Instant feedback for user interactions:
357
+
358
+ ```tsx
359
+ interface Post {
360
+ id: string
361
+ author: { name: string; avatar: string }
362
+ content: string
363
+ likes: number
364
+ likedByMe: boolean
365
+ createdAt: string
99
366
  }
100
367
 
101
- const createUser = defineMutation<CreateUserParams, User>({
102
- name: 'createUser', // Optional: used as mutation key
103
- mutate: (params) => api.post('/users', params)
368
+ const feedCollection = defineCollection<Post, { page?: number }>({
369
+ name: 'feed',
370
+ id: (post) => post.id,
371
+ fetch: ({ page = 1 }) => api.get(`/feed?page=${page}`)
372
+ })
373
+
374
+ const likePost = defineMutation<{ postId: string }, void>({
375
+ mutate: ({ postId }) => api.post(`/posts/${postId}/like`)
376
+ })
377
+
378
+ const unlikePost = defineMutation<{ postId: string }, void>({
379
+ mutate: ({ postId }) => api.delete(`/posts/${postId}/like`)
104
380
  })
381
+
382
+ function Feed() {
383
+ const [posts, query, pagination] = useQuery(feedCollection, {
384
+ paginated: true,
385
+ getPageParams: ({ pageParam = 1 }) => ({ page: pageParam })
386
+ })
387
+
388
+ const { mutate: like } = useMutation(likePost, {
389
+ optimistic: (channel, params) => {
390
+ channel(feedCollection).update(params.postId, post => ({
391
+ ...post,
392
+ likes: post.likes + 1,
393
+ likedByMe: true
394
+ }))
395
+ }
396
+ })
397
+
398
+ const { mutate: unlike } = useMutation(unlikePost, {
399
+ optimistic: (channel, params) => {
400
+ channel(feedCollection).update(params.postId, post => ({
401
+ ...post,
402
+ likes: post.likes - 1,
403
+ likedByMe: false
404
+ }))
405
+ }
406
+ })
407
+
408
+ return (
409
+ <div>
410
+ {posts?.map(post => (
411
+ <article key={post.id}>
412
+ <header>
413
+ <img src={post.author.avatar} alt={post.author.name} />
414
+ <span>{post.author.name}</span>
415
+ </header>
416
+ <p>{post.content}</p>
417
+ <footer>
418
+ <button onClick={() =>
419
+ post.likedByMe
420
+ ? unlike({ postId: post.id })
421
+ : like({ postId: post.id })
422
+ }>
423
+ {post.likedByMe ? '❤️' : '🤍'} {post.likes}
424
+ </button>
425
+ </footer>
426
+ </article>
427
+ ))}
428
+
429
+ {pagination.hasNextPage && (
430
+ <button
431
+ onClick={() => pagination.fetchNextPage()}
432
+ disabled={pagination.isFetchingNextPage}
433
+ >
434
+ {pagination.isFetchingNextPage ? 'Loading...' : 'Load More'}
435
+ </button>
436
+ )}
437
+ </div>
438
+ )
439
+ }
105
440
  ```
106
441
 
107
- ## Using Queries
442
+ ---
108
443
 
109
- ### Basic Query
444
+ ### Real-time Collaboration
110
445
 
111
- ```typescript
112
- function UserList() {
113
- const [users, { isLoading, error, refetch }] = useQuery(usersCollection)
446
+ Update multiple users viewing the same data:
447
+
448
+ ```tsx
449
+ interface Document {
450
+ id: string
451
+ title: string
452
+ content: string
453
+ lastEditedBy: string
454
+ updatedAt: string
455
+ }
456
+
457
+ const documentEntity = defineEntity<Document, string>({
458
+ name: 'document',
459
+ fetch: (docId) => api.get(`/documents/${docId}`)
460
+ })
114
461
 
115
- if (isLoading) return <Loading />
116
- if (error) return <Error error={error} />
462
+ const updateDocument = defineMutation<{ id: string; content: string }, Document>({
463
+ mutate: ({ id, content }) => api.patch(`/documents/${id}`, { content })
464
+ })
117
465
 
118
- return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
466
+ function DocumentEditor({ docId }: { docId: string }) {
467
+ const [doc, { isLoading }] = useQuery(documentEntity, { params: docId })
468
+ const [localContent, setLocalContent] = useState('')
469
+
470
+ useEffect(() => {
471
+ if (doc) setLocalContent(doc.content)
472
+ }, [doc?.content])
473
+
474
+ const { mutate: save, isPending } = useMutation(updateDocument, {
475
+ optimistic: (channel, params) => {
476
+ channel(documentEntity).update(d => ({
477
+ ...d,
478
+ content: params.content,
479
+ updatedAt: new Date().toISOString(),
480
+ lastEditedBy: 'You'
481
+ }))
482
+ }
483
+ })
484
+
485
+ // Auto-save with debounce
486
+ const debouncedSave = useMemo(
487
+ () => debounce((content: string) => save({ id: docId, content }), 1000),
488
+ [docId, save]
489
+ )
490
+
491
+ if (isLoading) return <div>Loading document...</div>
492
+
493
+ return (
494
+ <div>
495
+ <header>
496
+ <h1>{doc?.title}</h1>
497
+ <span>
498
+ {isPending ? 'Saving...' : `Last edited by ${doc?.lastEditedBy}`}
499
+ </span>
500
+ </header>
501
+ <textarea
502
+ value={localContent}
503
+ onChange={(e) => {
504
+ setLocalContent(e.target.value)
505
+ debouncedSave(e.target.value)
506
+ }}
507
+ />
508
+ </div>
509
+ )
510
+ }
511
+ ```
512
+
513
+ ---
514
+
515
+ ### Drag and Drop Reordering
516
+
517
+ Optimistic reordering for smooth UX:
518
+
519
+ ```tsx
520
+ interface Task {
521
+ id: string
522
+ title: string
523
+ order: number
524
+ }
525
+
526
+ const tasksCollection = defineCollection<Task>({
527
+ name: 'tasks',
528
+ id: (task) => task.id,
529
+ fetch: () => api.get('/tasks')
530
+ })
531
+
532
+ const reorderTask = defineMutation<{ id: string; newOrder: number }, Task[]>({
533
+ mutate: ({ id, newOrder }) => api.post(`/tasks/${id}/reorder`, { order: newOrder })
534
+ })
535
+
536
+ function TaskBoard() {
537
+ const [tasks] = useQuery(tasksCollection)
538
+ const sortedTasks = useMemo(
539
+ () => tasks?.slice().sort((a, b) => a.order - b.order),
540
+ [tasks]
541
+ )
542
+
543
+ const { mutate: reorder } = useMutation(reorderTask, {
544
+ optimistic: (channel, params) => {
545
+ channel(tasksCollection).updateWhere(
546
+ () => true, // Update all tasks
547
+ (task) => {
548
+ if (task.id === params.id) {
549
+ return { ...task, order: params.newOrder }
550
+ }
551
+ // Shift other tasks
552
+ if (task.order >= params.newOrder) {
553
+ return { ...task, order: task.order + 1 }
554
+ }
555
+ return task
556
+ }
557
+ )
558
+ }
559
+ })
560
+
561
+ const handleDrop = (draggedId: string, targetIndex: number) => {
562
+ reorder({ id: draggedId, newOrder: targetIndex })
563
+ }
564
+
565
+ return (
566
+ <div>
567
+ {sortedTasks?.map((task, index) => (
568
+ <div
569
+ key={task.id}
570
+ draggable
571
+ onDrop={() => handleDrop(task.id, index)}
572
+ >
573
+ {task.title}
574
+ </div>
575
+ ))}
576
+ </div>
577
+ )
119
578
  }
120
579
  ```
121
580
 
122
- ### With Parameters
581
+ ---
582
+
583
+ ## API Reference
584
+
585
+ ### Definitions
586
+
587
+ #### `defineCollection<TData, TParams>(config)`
588
+
589
+ Define a collection (array of items).
123
590
 
124
591
  ```typescript
125
- const [users] = useQuery(usersCollection, {
126
- params: { page: 2 }
592
+ const collection = defineCollection<User, { page: number }>({
593
+ name: 'users', // Unique identifier
594
+ id: (user) => user.id, // How to identify items
595
+ fetch: (params) => api.get(`/users?page=${params.page}`)
127
596
  })
128
597
  ```
129
598
 
130
- ### Entity Query
599
+ #### `defineEntity<TData, TParams>(config)`
600
+
601
+ Define an entity (single item).
131
602
 
132
603
  ```typescript
133
- const [profile] = useQuery(profileEntity, {
134
- params: userId
604
+ const entity = defineEntity<Profile, string>({
605
+ name: 'profile',
606
+ fetch: (userId) => api.get(`/users/${userId}/profile`)
135
607
  })
136
608
  ```
137
609
 
138
- ### Paginated (Infinite) Query
610
+ #### `defineMutation<TParams, TResponse>(config)`
611
+
612
+ Define a mutation.
139
613
 
140
614
  ```typescript
141
- const [posts, query, pagination] = useQuery(postsCollection, {
142
- paginated: true,
143
- getPageParams: ({ pageParam = 1 }) => ({ page: pageParam, limit: 10 })
615
+ const mutation = defineMutation<{ name: string }, User>({
616
+ name: 'createUser', // Optional: used as mutation key
617
+ mutate: (params) => api.post('/users', params)
144
618
  })
619
+ ```
620
+
621
+ ---
622
+
623
+ ### Hooks
145
624
 
146
- // Load more
147
- <button onClick={pagination.fetchNextPage} disabled={!pagination.hasNextPage}>
148
- Load More
149
- </button>
625
+ #### `useQuery(definition, options?)`
626
+
627
+ Fetch data from a collection or entity.
628
+
629
+ ```typescript
630
+ // Collection
631
+ const [users, queryState] = useQuery(usersCollection)
632
+ const [users, queryState] = useQuery(usersCollection, { params: { page: 2 } })
633
+
634
+ // Entity
635
+ const [profile, queryState] = useQuery(profileEntity, { params: userId })
636
+
637
+ // Paginated
638
+ const [posts, queryState, pagination] = useQuery(postsCollection, {
639
+ paginated: true,
640
+ getPageParams: ({ pageParam = 1 }) => ({ page: pageParam })
641
+ })
150
642
  ```
151
643
 
152
- ## Optimistic Updates with Channel API
644
+ **Options:**
645
+ | Option | Type | Description |
646
+ |--------|------|-------------|
647
+ | `params` | `TParams` | Parameters for fetch function |
648
+ | `enabled` | `boolean` | Enable/disable query |
649
+ | `staleTime` | `number` | Time before data is stale (ms) |
650
+ | `refetchOnMount` | `boolean` | Refetch on component mount |
651
+ | `refetchOnWindowFocus` | `boolean` | Refetch when window focuses |
652
+ | `refetchInterval` | `number` | Polling interval (ms) |
653
+ | `paginated` | `boolean` | Enable infinite query mode |
654
+ | `getPageParams` | `function` | Transform page context to params |
153
655
 
154
- The channel API provides an imperative way to apply optimistic updates:
656
+ **Returns:**
657
+ - `data` - The fetched data (or `undefined`)
658
+ - `queryState` - `{ isLoading, isFetching, error, refetch, ... }`
659
+ - `pagination` (paginated only) - `{ hasNextPage, fetchNextPage, isFetchingNextPage, ... }`
660
+
661
+ ---
662
+
663
+ #### `useMutation(definition, options?)`
664
+
665
+ Execute mutations with optional optimistic updates.
155
666
 
156
667
  ```typescript
157
- const { mutate } = useMutation(createUser, {
158
- optimistic: (channel, params) => {
159
- // Add to collection immediately
160
- channel(usersCollection).prepend({
161
- id: 'temp-' + Date.now(),
162
- name: params.name,
163
- email: params.email,
164
- }, { sync: true }) // sync: replace with server response
668
+ const { mutate, mutateAsync, isPending, isError, error, data, reset } = useMutation(
669
+ createUser,
670
+ {
671
+ optimistic: (channel, params) => {
672
+ channel(usersCollection).append(params, { reconcile: true })
673
+ },
674
+ onSuccess: (data) => console.log('Created:', data),
675
+ onError: (error) => console.error('Failed:', error)
165
676
  }
166
- })
677
+ )
167
678
  ```
168
679
 
169
- ### Collection Operations
680
+ **Options:**
681
+ | Option | Type | Description |
682
+ |--------|------|-------------|
683
+ | `optimistic` | `(channel, params) => void` | Apply optimistic updates |
684
+ | `onMutate` | `(params) => void` | Called when mutation starts |
685
+ | `onSuccess` | `(data, params) => void` | Called on success |
686
+ | `onError` | `(error, params) => void` | Called on error |
687
+
688
+ ---
689
+
690
+ ### Channel API
691
+
692
+ The channel provides intuitive methods for optimistic updates.
693
+
694
+ #### Collection Methods
170
695
 
171
696
  ```typescript
172
697
  optimistic: (channel, params) => {
173
698
  const ch = channel(usersCollection)
174
699
 
175
700
  // Add items
176
- ch.prepend(newItem, { sync: true })
177
- ch.append(newItem, { sync: true })
701
+ ch.prepend(newItem, { reconcile: true }) // Add to beginning
702
+ ch.append(newItem, { reconcile: true }) // Add to end
178
703
 
179
704
  // Update by ID
180
- ch.update(params.id, user => ({
181
- ...user,
182
- name: params.newName
183
- }))
705
+ ch.update(id, item => ({ ...item, name: newName }))
706
+
707
+ // Update matching items
708
+ ch.updateWhere(
709
+ item => item.status === 'active',
710
+ item => ({ ...item, highlighted: true })
711
+ )
184
712
 
185
713
  // Delete by ID
186
- ch.delete(params.id)
714
+ ch.delete(id)
715
+
716
+ // Delete matching items
717
+ ch.deleteWhere(item => item.expired)
187
718
  }
188
719
  ```
189
720
 
190
- ### Entity Operations
721
+ #### Entity Methods
191
722
 
192
723
  ```typescript
193
724
  optimistic: (channel, params) => {
194
- channel(profileEntity).update(profile => ({
195
- ...profile,
196
- name: params.newName
197
- }))
725
+ const ch = channel(profileEntity)
198
726
 
199
- // Or replace entirely
200
- channel(profileEntity).replace(newProfile)
727
+ // Partial update
728
+ ch.update(profile => ({ ...profile, name: newName }))
729
+
730
+ // Full replacement
731
+ ch.replace(newProfile)
201
732
  }
202
733
  ```
203
734
 
204
- ### Multiple Updates
735
+ #### Reconcile Option
205
736
 
206
- Update multiple collections/entities in a single mutation:
737
+ Use `{ reconcile: true }` when the server response should replace the optimistic data:
207
738
 
208
739
  ```typescript
209
- const { mutate } = useMutation(deleteUser, {
210
- optimistic: (channel, params) => {
211
- // Remove from users list
212
- channel(usersCollection).delete(params.userId)
213
-
214
- // Update stats
215
- channel(statsEntity).update(stats => ({
216
- ...stats,
217
- userCount: stats.userCount - 1
218
- }))
219
- }
220
- })
740
+ // The temp ID will be replaced with the server's real ID
741
+ channel(todosCollection).append({
742
+ id: `temp-${Date.now()}`,
743
+ title: 'New todo'
744
+ }, { reconcile: true })
221
745
  ```
222
746
 
223
- ## Standalone Channel (Outside Mutations)
747
+ ---
224
748
 
225
- Use the channel directly for immediate updates with manual rollback:
749
+ ### Standalone Channel
750
+
751
+ Use outside mutations for manual control:
226
752
 
227
753
  ```typescript
228
- import { channel } from 'query-toolkit'
754
+ import { channel } from 'query-optimistic'
229
755
 
230
- async function handleLike(postId: string) {
231
- // Apply optimistic update immediately
756
+ async function handleQuickAction(postId: string) {
757
+ // Apply immediately
232
758
  const rollback = channel(postsCollection).update(postId, post => ({
233
759
  ...post,
234
760
  likes: post.likes + 1
@@ -237,135 +763,115 @@ async function handleLike(postId: string) {
237
763
  try {
238
764
  await api.post(`/posts/${postId}/like`)
239
765
  } catch (error) {
240
- // Undo on failure
241
- rollback()
766
+ rollback() // Undo on failure
242
767
  }
243
768
  }
244
769
  ```
245
770
 
246
- ## Optimistic Status
771
+ ---
772
+
773
+ ### Optimistic Status
247
774
 
248
- Items with pending optimistic updates include metadata for UI feedback:
775
+ Track pending operations in your UI:
249
776
 
250
777
  ```typescript
251
- {users?.map(user => (
252
- <div
253
- key={user.id}
254
- style={{ opacity: user._optimistic?.status === 'pending' ? 0.5 : 1 }}
255
- >
256
- {user.name}
257
- {user._optimistic?.status === 'error' && (
778
+ interface OptimisticMeta {
779
+ status: 'pending' | 'error'
780
+ error?: Error
781
+ }
782
+
783
+ // Available on items during optimistic updates
784
+ {items?.map(item => (
785
+ <div style={{
786
+ opacity: item._optimistic?.status === 'pending' ? 0.5 : 1
787
+ }}>
788
+ {item.name}
789
+ {item._optimistic?.status === 'error' && (
258
790
  <span className="error">Failed to save</span>
259
791
  )}
260
792
  </div>
261
793
  ))}
262
794
  ```
263
795
 
264
- ## API Reference
265
-
266
- ### `defineCollection<TData, TParams>(config)`
267
-
268
- | Property | Type | Description |
269
- |----------|------|-------------|
270
- | `name` | `string` | Unique identifier |
271
- | `id` | `(item: TData) => string` | Extract ID from item |
272
- | `fetch` | `(params: TParams) => Promise<TData[]>` | Fetch function |
273
-
274
- ### `defineEntity<TData, TParams>(config)`
275
-
276
- | Property | Type | Description |
277
- |----------|------|-------------|
278
- | `name` | `string` | Unique identifier |
279
- | `fetch` | `(params: TParams) => Promise<TData>` | Fetch function |
280
-
281
- ### `defineMutation<TParams, TResponse>(config)`
796
+ ---
282
797
 
283
- | Property | Type | Description |
284
- |----------|------|-------------|
285
- | `name?` | `string` | Optional mutation key |
286
- | `mutate` | `(params: TParams) => Promise<TResponse>` | Mutation function |
287
-
288
- ### `useQuery(def, options?)`
289
-
290
- Returns `[data, queryState]` or `[data, queryState, paginationState]` for paginated queries.
291
-
292
- **Options:**
293
- - `params` - Parameters for fetch function
294
- - `enabled` - Enable/disable query
295
- - `staleTime` - Time before data is stale (ms)
296
- - `refetchOnMount` - Refetch on component mount
297
- - `refetchOnWindowFocus` - Refetch when window focuses
298
- - `refetchInterval` - Polling interval (ms)
299
- - `paginated` - Enable infinite query mode
300
- - `getPageParams` - Transform page context to params
301
-
302
- ### `useMutation(def, options?)`
798
+ ## TypeScript
303
799
 
304
- Returns `{ mutate, mutateAsync, isPending, isError, isSuccess, error, data, reset }`.
800
+ Full type inference flows from definitions to usage:
305
801
 
306
- **Options:**
307
- - `optimistic` - `(channel, params) => void` - Apply optimistic updates
308
- - `onMutate` - Called when mutation starts
309
- - `onSuccess` - Called on success
310
- - `onError` - Called on error
802
+ ```typescript
803
+ // Define with types
804
+ const usersCollection = defineCollection<User, { page: number }>({
805
+ name: 'users',
806
+ id: (user) => user.id, // user: User
807
+ fetch: ({ page }) => api.get(`/users?page=${page}`) // page: number
808
+ })
311
809
 
312
- ### `channel(target)`
810
+ const createUser = defineMutation<CreateUserParams, User>({
811
+ mutate: (params) => api.post('/users', params) // params: CreateUserParams
812
+ })
313
813
 
314
- Standalone channel for immediate optimistic updates.
814
+ // Types flow automatically
815
+ const [users] = useQuery(usersCollection, {
816
+ params: { page: 1 } // TypeScript enforces { page: number }
817
+ })
818
+ // users: User[] | undefined
315
819
 
316
- ```typescript
317
- // For collections
318
- channel(collection).prepend(item, { sync?: boolean })
319
- channel(collection).append(item, { sync?: boolean })
320
- channel(collection).update(id, updateFn, { sync?: boolean })
321
- channel(collection).updateWhere(predicate, updateFn)
322
- channel(collection).delete(id)
323
- channel(collection).deleteWhere(predicate)
324
-
325
- // For entities
326
- channel(entity).update(updateFn, { sync?: boolean })
327
- channel(entity).replace(data, { sync?: boolean })
820
+ const { mutate } = useMutation(createUser, {
821
+ optimistic: (channel, params) => {
822
+ // params: CreateUserParams (inferred)
823
+ channel(usersCollection).append({
824
+ // TypeScript ensures this matches User
825
+ })
826
+ }
827
+ })
828
+ // mutate: (params: CreateUserParams) => void
328
829
  ```
329
830
 
330
- All methods return a rollback function.
831
+ ---
331
832
 
332
833
  ## Submodule Imports
333
834
 
334
835
  ```typescript
836
+ // Full library
837
+ import { defineCollection, useQuery, useMutation } from 'query-optimistic'
838
+
335
839
  // Core only (no React dependency)
336
- import { defineCollection, defineEntity, defineMutation, channel } from 'query-toolkit/core'
840
+ import { defineCollection, defineEntity, defineMutation, channel } from 'query-optimistic/core'
337
841
 
338
842
  // React hooks only
339
- import { useQuery, useMutation } from 'query-toolkit/react'
843
+ import { useQuery, useMutation } from 'query-optimistic/react'
340
844
  ```
341
845
 
342
- ## TypeScript
846
+ ---
343
847
 
344
- Full type inference from definitions:
848
+ ## Peer Dependencies
345
849
 
346
- ```typescript
347
- const usersCollection = defineCollection<User, { page: number }>({...})
348
- const createUser = defineMutation<CreateUserParams, User>({...})
850
+ | Package | Version |
851
+ |---------|---------|
852
+ | `react` | >= 18.0.0 |
853
+ | `@tanstack/react-query` | >= 5.0.0 |
349
854
 
350
- // Types flow automatically
351
- const [users] = useQuery(usersCollection, { params: { page: 1 } })
352
- // users: User[] | undefined
855
+ ---
353
856
 
354
- const { mutate } = useMutation(createUser, {
355
- optimistic: (channel, params) => {
356
- // params: CreateUserParams
357
- channel(usersCollection).prepend({...})
358
- // Type-checks that data matches User
359
- }
360
- })
361
- // mutate: (params: CreateUserParams) => void
362
- ```
857
+ ## Contributing
363
858
 
364
- ## Peer Dependencies
859
+ Contributions are welcome! Please feel free to submit a Pull Request.
365
860
 
366
- - `react` >= 18.0.0
367
- - `@tanstack/react-query` >= 5.0.0
861
+ 1. Fork the repository
862
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
863
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
864
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
865
+ 5. Open a Pull Request
866
+
867
+ ---
368
868
 
369
869
  ## License
370
870
 
371
- MIT
871
+ MIT - see [LICENSE](./LICENSE) for details.
872
+
873
+ ---
874
+
875
+ <p align="center">
876
+ Made with care for the React community
877
+ </p>