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 +721 -215
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.d.ts +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -6
- package/dist/index.mjs.map +1 -1
- package/dist/react/index.d.mts +9 -9
- package/dist/react/index.d.ts +9 -9
- package/dist/react/index.js +6 -6
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +6 -6
- package/dist/react/index.mjs.map +1 -1
- package/dist/{types-BRxQA1mR.d.mts → types-BOq5W_Qm.d.mts} +1 -1
- package/dist/{types-BRxQA1mR.d.ts → types-BOq5W_Qm.d.ts} +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,64 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
53
|
+
// After: query-optimistic
|
|
54
|
+
useMutation(createTodo, {
|
|
55
|
+
optimistic: (channel, todo) => {
|
|
56
|
+
channel(todosCollection).append(todo, { reconcile: true })
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
```
|
|
8
60
|
|
|
9
|
-
|
|
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,
|
|
84
|
+
import { defineCollection, defineEntity, defineMutation } from 'query-optimistic'
|
|
25
85
|
|
|
26
|
-
//
|
|
27
|
-
const
|
|
28
|
-
name: '
|
|
29
|
-
id: (
|
|
30
|
-
fetch: () => api.get('/
|
|
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
|
-
//
|
|
34
|
-
const
|
|
35
|
-
|
|
93
|
+
// An entity is a single item
|
|
94
|
+
const userEntity = defineEntity({
|
|
95
|
+
name: 'currentUser',
|
|
96
|
+
fetch: () => api.get('/me')
|
|
36
97
|
})
|
|
37
98
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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(
|
|
45
|
-
id:
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
<
|
|
54
|
-
|
|
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
|
-
|
|
145
|
+
---
|
|
61
146
|
|
|
62
|
-
|
|
147
|
+
## Examples
|
|
63
148
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
158
|
+
title: string
|
|
159
|
+
completed: boolean
|
|
160
|
+
createdAt: string
|
|
69
161
|
}
|
|
70
162
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
254
|
+
---
|
|
79
255
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
265
|
+
price: number
|
|
266
|
+
quantity: number
|
|
85
267
|
}
|
|
86
268
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
352
|
+
---
|
|
94
353
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
102
|
-
name: '
|
|
103
|
-
|
|
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
|
-
|
|
442
|
+
---
|
|
108
443
|
|
|
109
|
-
###
|
|
444
|
+
### Real-time Collaboration
|
|
110
445
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
462
|
+
const updateDocument = defineMutation<{ id: string; content: string }, Document>({
|
|
463
|
+
mutate: ({ id, content }) => api.patch(`/documents/${id}`, { content })
|
|
464
|
+
})
|
|
117
465
|
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
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
|
-
|
|
599
|
+
#### `defineEntity<TData, TParams>(config)`
|
|
600
|
+
|
|
601
|
+
Define an entity (single item).
|
|
131
602
|
|
|
132
603
|
```typescript
|
|
133
|
-
const
|
|
134
|
-
|
|
604
|
+
const entity = defineEntity<Profile, string>({
|
|
605
|
+
name: 'profile',
|
|
606
|
+
fetch: (userId) => api.get(`/users/${userId}/profile`)
|
|
135
607
|
})
|
|
136
608
|
```
|
|
137
609
|
|
|
138
|
-
|
|
610
|
+
#### `defineMutation<TParams, TResponse>(config)`
|
|
611
|
+
|
|
612
|
+
Define a mutation.
|
|
139
613
|
|
|
140
614
|
```typescript
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
channel
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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, {
|
|
177
|
-
ch.append(newItem, {
|
|
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(
|
|
181
|
-
|
|
182
|
-
|
|
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(
|
|
714
|
+
ch.delete(id)
|
|
715
|
+
|
|
716
|
+
// Delete matching items
|
|
717
|
+
ch.deleteWhere(item => item.expired)
|
|
187
718
|
}
|
|
188
719
|
```
|
|
189
720
|
|
|
190
|
-
|
|
721
|
+
#### Entity Methods
|
|
191
722
|
|
|
192
723
|
```typescript
|
|
193
724
|
optimistic: (channel, params) => {
|
|
194
|
-
channel(profileEntity)
|
|
195
|
-
...profile,
|
|
196
|
-
name: params.newName
|
|
197
|
-
}))
|
|
725
|
+
const ch = channel(profileEntity)
|
|
198
726
|
|
|
199
|
-
//
|
|
200
|
-
|
|
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
|
-
|
|
735
|
+
#### Reconcile Option
|
|
205
736
|
|
|
206
|
-
|
|
737
|
+
Use `{ reconcile: true }` when the server response should replace the optimistic data:
|
|
207
738
|
|
|
208
739
|
```typescript
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
747
|
+
---
|
|
224
748
|
|
|
225
|
-
|
|
749
|
+
### Standalone Channel
|
|
750
|
+
|
|
751
|
+
Use outside mutations for manual control:
|
|
226
752
|
|
|
227
753
|
```typescript
|
|
228
|
-
import { channel } from 'query-
|
|
754
|
+
import { channel } from 'query-optimistic'
|
|
229
755
|
|
|
230
|
-
async function
|
|
231
|
-
// Apply
|
|
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
|
-
|
|
771
|
+
---
|
|
772
|
+
|
|
773
|
+
### Optimistic Status
|
|
247
774
|
|
|
248
|
-
|
|
775
|
+
Track pending operations in your UI:
|
|
249
776
|
|
|
250
777
|
```typescript
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
800
|
+
Full type inference flows from definitions to usage:
|
|
305
801
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
810
|
+
const createUser = defineMutation<CreateUserParams, User>({
|
|
811
|
+
mutate: (params) => api.post('/users', params) // params: CreateUserParams
|
|
812
|
+
})
|
|
313
813
|
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
channel(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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-
|
|
840
|
+
import { defineCollection, defineEntity, defineMutation, channel } from 'query-optimistic/core'
|
|
337
841
|
|
|
338
842
|
// React hooks only
|
|
339
|
-
import { useQuery, useMutation } from 'query-
|
|
843
|
+
import { useQuery, useMutation } from 'query-optimistic/react'
|
|
340
844
|
```
|
|
341
845
|
|
|
342
|
-
|
|
846
|
+
---
|
|
343
847
|
|
|
344
|
-
|
|
848
|
+
## Peer Dependencies
|
|
345
849
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
850
|
+
| Package | Version |
|
|
851
|
+
|---------|---------|
|
|
852
|
+
| `react` | >= 18.0.0 |
|
|
853
|
+
| `@tanstack/react-query` | >= 5.0.0 |
|
|
349
854
|
|
|
350
|
-
|
|
351
|
-
const [users] = useQuery(usersCollection, { params: { page: 1 } })
|
|
352
|
-
// users: User[] | undefined
|
|
855
|
+
---
|
|
353
856
|
|
|
354
|
-
|
|
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
|
-
|
|
859
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
365
860
|
|
|
366
|
-
|
|
367
|
-
-
|
|
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>
|