@wisemen/vue-core-api-utils 1.0.1 → 1.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/dist/index.d.mts +25 -18
- package/dist/index.mjs +10 -1
- package/package.json +12 -3
- package/skills/asyncresult-handling/SKILL.md +181 -0
- package/skills/cache-management/SKILL.md +222 -0
- package/skills/foundations/SKILL.md +461 -0
- package/skills/getting-started/SKILL.md +248 -0
- package/skills/optimistic-uis/SKILL.md +402 -0
- package/skills/writing-infinitequeries/SKILL.md +243 -0
- package/skills/writing-mutations/SKILL.md +240 -0
- package/skills/writing-queries/SKILL.md +205 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: optimistic-uis
|
|
3
|
+
description: >
|
|
4
|
+
Combining mutations, cache updates, and AsyncResult to create responsive UIs with instant feedback; optimistic updates with error handling, async transitions, immediate user feedback without request latency.
|
|
5
|
+
type: core
|
|
6
|
+
library: vue-core-api-utils
|
|
7
|
+
library_version: "0.0.3"
|
|
8
|
+
sources:
|
|
9
|
+
- "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/usage/mutation.md"
|
|
10
|
+
- "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/usage/query-client.md"
|
|
11
|
+
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/composables/mutation/mutation.composable.ts"
|
|
12
|
+
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/utils/query-client/queryClient.ts"
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# @wisemen/vue-core-api-utils — Optimistic UIs
|
|
16
|
+
|
|
17
|
+
Create fast, responsive UIs by updating the cache immediately while mutations execute in the background. Combine `useMutation()`, `useQueryClient()`, and `AsyncResult` pattern matching to provide instant feedback to users.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { useMutation, useQueryClient, useQuery } from '@/api'
|
|
23
|
+
import { computed } from 'vue'
|
|
24
|
+
|
|
25
|
+
const queryClient = useQueryClient()
|
|
26
|
+
const { result: contact } = useQuery('contactDetail', {
|
|
27
|
+
params: {
|
|
28
|
+
contactUuid: computed(() => contactUuid),
|
|
29
|
+
},
|
|
30
|
+
queryFn: () => ContactService.getDetail(contactUuid),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const { execute, isLoading, result: mutationResult } = useMutation({
|
|
34
|
+
queryFn: ({ body }) => ContactService.updateContact(contactUuid, body),
|
|
35
|
+
queryKeysToInvalidate: { contactList: {} },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
async function handleSubmit(formData) {
|
|
39
|
+
// Save original (for rollback)
|
|
40
|
+
const originalContact = contact.value.isOk() ? contact.value.getValue() : null
|
|
41
|
+
|
|
42
|
+
// Optimistic update: immediate cache change
|
|
43
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
44
|
+
by: (c) => true,
|
|
45
|
+
value: (c) => ({ ...c, ...formData }),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Execute mutation
|
|
49
|
+
const result = await execute(formData)
|
|
50
|
+
|
|
51
|
+
// On error, rollback
|
|
52
|
+
if (result.isErr()) {
|
|
53
|
+
queryClient.set(['contactDetail', { contactUuid }], originalContact)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Core Patterns
|
|
59
|
+
|
|
60
|
+
### Immediate cache update while request pending
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
const { result } = useQuery('contactDetail', {
|
|
64
|
+
params: computed(() => ({ contactUuid })),
|
|
65
|
+
queryFn: () => ContactService.getDetail(contactUuid),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const { execute, isLoading } = useMutation({
|
|
69
|
+
queryFn: (data) => ContactService.updateContact(contactUuid, data),
|
|
70
|
+
queryKeysToInvalidate: { /* ... */ },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
async function handleSave(formData) {
|
|
74
|
+
// Cache update happens immediately
|
|
75
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
76
|
+
by: (c) => true,
|
|
77
|
+
value: (c) => ({ ...c, ...formData }),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Mutation executes in background
|
|
81
|
+
await execute(formData)
|
|
82
|
+
|
|
83
|
+
// UI shows updated data from cache right away
|
|
84
|
+
// isLoading is true while request pending
|
|
85
|
+
// result.value changes when mutation completes
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Users see changes instantly. `isLoading` stays true during request, giving visual feedback. No perceived latency.
|
|
90
|
+
|
|
91
|
+
### Error handling with AsyncResult
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
async function handleSave(formData) {
|
|
95
|
+
const originalContact = contact.value?.getValue()
|
|
96
|
+
|
|
97
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
98
|
+
by: (c) => true,
|
|
99
|
+
value: (c) => ({ ...c, ...formData }),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const result = await execute(formData)
|
|
103
|
+
|
|
104
|
+
// Match on mutation result
|
|
105
|
+
result.match({
|
|
106
|
+
ok: () => {
|
|
107
|
+
// Server confirmed the update
|
|
108
|
+
// Cache already reflects the change
|
|
109
|
+
showSuccessMessage('Contact updated')
|
|
110
|
+
},
|
|
111
|
+
err: (error) => {
|
|
112
|
+
// Revert optimistic update
|
|
113
|
+
queryClient.set(['contactDetail', { contactUuid }], originalContact)
|
|
114
|
+
showErrorMessage(`Failed: ${error.message}`)
|
|
115
|
+
},
|
|
116
|
+
loading: () => {
|
|
117
|
+
// Should not happen after await, but handle just in case
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
When mutation fails, revert the optimistic change using the saved original value.
|
|
124
|
+
|
|
125
|
+
### Composable combining query + mutation + optimistic UI
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
export function useContactEditor(contactUuid) {
|
|
129
|
+
const queryClient = useQueryClient()
|
|
130
|
+
|
|
131
|
+
const { result: contact } = useQuery('contactDetail', {
|
|
132
|
+
params: computed(() => ({ contactUuid })),
|
|
133
|
+
queryFn: () => ContactService.getDetail(contactUuid),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const { execute, isLoading, result: mutationResult } = useMutation({
|
|
137
|
+
queryFn: (data) => ContactService.updateContact(contactUuid, data),
|
|
138
|
+
queryKeysToInvalidate: {
|
|
139
|
+
contactList: () => true,
|
|
140
|
+
'contact-stats': () => true,
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
async function saveContact(formData) {
|
|
145
|
+
const original = contact.value?.getValue()
|
|
146
|
+
|
|
147
|
+
// Optimistic
|
|
148
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
149
|
+
by: () => true,
|
|
150
|
+
value: (c) => ({ ...c, ...formData }),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Execute
|
|
154
|
+
const result = await execute(formData)
|
|
155
|
+
|
|
156
|
+
// Rollback on error
|
|
157
|
+
if (result.isErr()) {
|
|
158
|
+
queryClient.set(['contactDetail', { contactUuid }], original)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
contact,
|
|
166
|
+
saveContact,
|
|
167
|
+
isLoading,
|
|
168
|
+
mutationResult,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Encapsulate the full flow in a composable for reusability across components.
|
|
174
|
+
|
|
175
|
+
## Common Mistakes
|
|
176
|
+
|
|
177
|
+
### HIGH: Forget to save original data before optimistic update; can't rollback on error
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
// ❌ Wrong: no original saved, rollback impossible
|
|
181
|
+
const { execute, result } = useMutation({
|
|
182
|
+
queryFn: (data) => ContactService.updateContact(data),
|
|
183
|
+
queryKeysToInvalidate: { contactList: () => true },
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
async function handleSave(formData) {
|
|
187
|
+
// Update cache immediately
|
|
188
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
189
|
+
by: () => true,
|
|
190
|
+
value: (c) => ({ ...c, ...formData }),
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const result = await execute(formData)
|
|
194
|
+
|
|
195
|
+
if (result.isErr()) {
|
|
196
|
+
// No way to undo the optimistic change!
|
|
197
|
+
showErrorMessage('Save failed')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
// ✅ Correct: save original before update
|
|
204
|
+
async function handleSave(formData) {
|
|
205
|
+
// Save original FIRST
|
|
206
|
+
const originalContact = contact.value?.getValue()
|
|
207
|
+
|
|
208
|
+
// Update cache
|
|
209
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
210
|
+
by: () => true,
|
|
211
|
+
value: (c) => ({ ...c, ...formData }),
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
const result = await execute(formData)
|
|
215
|
+
|
|
216
|
+
if (result.isErr()) {
|
|
217
|
+
// Restore original
|
|
218
|
+
queryClient.set(['contactDetail', { contactUuid }], originalContact)
|
|
219
|
+
showErrorMessage('Save failed, changes reverted')
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Always save the current value before modifying the cache. Use it to rollback if the mutation fails.
|
|
225
|
+
|
|
226
|
+
Source: `docs/packages/api-utils/pages/usage/query-client.md` Real-World Example
|
|
227
|
+
|
|
228
|
+
### CRITICAL: Handle stale optimistic updates; if component unmounts, optimistic change remains in cache
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
// ❌ Wrong: optimistic update lives in cache even if user navigates away
|
|
232
|
+
async function handleSave(formData) {
|
|
233
|
+
const originalContact = contact.value?.getValue()
|
|
234
|
+
|
|
235
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
236
|
+
by: () => true,
|
|
237
|
+
value: (c) => ({ ...c, ...formData }),
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// User navigates away while mutation pending
|
|
241
|
+
// Cache still has optimistic data
|
|
242
|
+
// When user returns, data is stale
|
|
243
|
+
const result = await execute(formData)
|
|
244
|
+
if (result.isErr()) {
|
|
245
|
+
// Rollback happens but user is gone
|
|
246
|
+
queryClient.set(['contactDetail', { contactUuid }], originalContact)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// ✅ Correct: clear optimistic flag or track mutation completion
|
|
253
|
+
import { onUnmounted } from 'vue'
|
|
254
|
+
|
|
255
|
+
let originalContact = null
|
|
256
|
+
let isSaving = false
|
|
257
|
+
|
|
258
|
+
async function handleSave(formData) {
|
|
259
|
+
originalContact = contact.value?.getValue()
|
|
260
|
+
isSaving = true
|
|
261
|
+
|
|
262
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
263
|
+
by: () => true,
|
|
264
|
+
value: (c) => ({ ...c, ...formData }),
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const result = await execute(formData)
|
|
268
|
+
isSaving = false
|
|
269
|
+
|
|
270
|
+
if (result.isErr() && originalContact) {
|
|
271
|
+
queryClient.set(['contactDetail', { contactUuid }], originalContact)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
onUnmounted(() => {
|
|
276
|
+
// If still saving and user leaves, rollback
|
|
277
|
+
if (isSaving && originalContact) {
|
|
278
|
+
queryClient.set(['contactDetail', { contactUuid }], originalContact)
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
If user navigates away while a mutation is pending, rollback the optimistic change using component lifecycle hooks. Otherwise, stale data persists in the cache.
|
|
284
|
+
|
|
285
|
+
Source: Architectural consideration from cache invalidation patterns
|
|
286
|
+
|
|
287
|
+
### HIGH: Miss querying the correct data structure; optimistic update patches wrong field
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
// ❌ Wrong: update assuming flat entity, but query returns paginated structure
|
|
291
|
+
const { result: paginatedContacts } = useQuery('contactList', {
|
|
292
|
+
params: computed(() => ({ limit: 20, offset: 0 })),
|
|
293
|
+
queryFn: () => ContactService.list({ limit: 20, offset: 0 }),
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
queryClient.update('contactList', {
|
|
297
|
+
by: (contact) => contact.id === '123',
|
|
298
|
+
value: (contact) => ({ ...contact, name: 'Updated' }),
|
|
299
|
+
})
|
|
300
|
+
// This fails because contactList is not Contact[] but { data: Contact[], meta: {...} }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
// ✅ Correct: QueryClient handles nested structures automatically
|
|
305
|
+
const { result: paginatedContacts } = useQuery('contactList', {
|
|
306
|
+
params: computed(() => ({ limit: 20, offset: 0 })),
|
|
307
|
+
queryFn: () => ContactService.list({ limit: 20, offset: 0 }),
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// QueryClient infers the data structure from query keys
|
|
311
|
+
// If contactList query returns { data: Contact[], meta: {...} }
|
|
312
|
+
// QueryClient knows to iterate contact.data and apply the predicate
|
|
313
|
+
queryClient.update('contactList', {
|
|
314
|
+
by: (contact) => contact.id === '123',
|
|
315
|
+
value: (contact) => ({ ...contact, name: 'Updated' }),
|
|
316
|
+
})
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
QueryClient automatically extracts the entity from nested query results (`{ data: [...], meta: {...} }`). You work with the flattened entity, QueryClient handles the nesting. This is why checking your query key definition matters.
|
|
320
|
+
|
|
321
|
+
Source: `packages/web/api-utils/src/utils/query-client/queryClient.ts`
|
|
322
|
+
|
|
323
|
+
### MEDIUM: Race condition: multiple mutations affect the same query, order matters
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
// ❌ Wrong: two mutations in flight affecting the same cache
|
|
327
|
+
async function handleMultipleSaves() {
|
|
328
|
+
// Mutation 1: add tag
|
|
329
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
330
|
+
by: () => true,
|
|
331
|
+
value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
|
|
332
|
+
})
|
|
333
|
+
const result1 = await execute({ tags: [...contact.value.tags, 'new-tag'] })
|
|
334
|
+
|
|
335
|
+
// Mutation 2: change name
|
|
336
|
+
// But Mutation 1 is still in flight!
|
|
337
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
338
|
+
by: () => true,
|
|
339
|
+
value: (c) => ({ ...c, name: 'Updated' }),
|
|
340
|
+
})
|
|
341
|
+
const result2 = await execute({ name: 'Updated' })
|
|
342
|
+
|
|
343
|
+
// If Mutation 2 completes before Mutation 1, the tags are lost
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
// ✅ Correct: serialize mutations or track multiple originals
|
|
349
|
+
async function handleMultipleSaves() {
|
|
350
|
+
const original = contact.value?.getValue()
|
|
351
|
+
|
|
352
|
+
// Either: await each mutation before starting the next
|
|
353
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
354
|
+
by: () => true,
|
|
355
|
+
value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
|
|
356
|
+
})
|
|
357
|
+
const result1 = await execute({ tags: [...contact.value.tags, 'new-tag'] })
|
|
358
|
+
if (result1.isErr()) {
|
|
359
|
+
queryClient.set(['contactDetail', { contactUuid }], original)
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Now safe to do second mutation
|
|
364
|
+
const updatedOriginal = queryClient.get(['contactDetail', { contactUuid }])
|
|
365
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
366
|
+
by: () => true,
|
|
367
|
+
value: (c) => ({ ...c, name: 'Updated' }),
|
|
368
|
+
})
|
|
369
|
+
const result2 = await execute({ name: 'Updated' })
|
|
370
|
+
if (result2.isErr()) {
|
|
371
|
+
queryClient.set(['contactDetail', { contactUuid }], updatedOriginal)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Mutations should be serialized (one at a time) or you need to track the state after each optimistic update. Concurrent mutations on the same cache lead to lost updates.
|
|
377
|
+
|
|
378
|
+
Source: Architectural consideration from query lifecycle patterns
|
|
379
|
+
|
|
380
|
+
## Rollback Strategy
|
|
381
|
+
|
|
382
|
+
Rollback is enabled by saving the original data before the optimistic update:
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
const original = contact.value?.getValue()
|
|
386
|
+
queryClient.update(['contactDetail', { contactUuid }], {
|
|
387
|
+
by: () => true,
|
|
388
|
+
value: (c) => ({ ...c, ...formData }),
|
|
389
|
+
})
|
|
390
|
+
const result = await execute(formData)
|
|
391
|
+
if (result.isErr()) {
|
|
392
|
+
queryClient.set(['contactDetail', { contactUuid }], original)
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
However, rollback patterns for complex scenarios (partial field updates, nested objects) are being refined in the library. For now, save and restore the entire entity.
|
|
397
|
+
|
|
398
|
+
## See Also
|
|
399
|
+
|
|
400
|
+
- [Writing Mutations](../writing-mutations/SKILL.md) — The `execute()` and result handling that pairs with optimistic updates
|
|
401
|
+
- [Cache Management](../cache-management/SKILL.md) — QueryClient methods for reading and updating cache
|
|
402
|
+
- [Writing Queries](../writing-queries/SKILL.md) — Understanding query results and caching behavior
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: writing-infinitequeries
|
|
3
|
+
description: >
|
|
4
|
+
Infinite pagination with useOffsetInfiniteQuery and useKeysetInfiniteQuery, offset vs keyset strategies determined by backend API, fetchNextPage, hasNextPage, isFetchingNextPage, data/meta result structure, proper page assembly.
|
|
5
|
+
type: core
|
|
6
|
+
library: vue-core-api-utils
|
|
7
|
+
library_version: "0.0.3"
|
|
8
|
+
sources:
|
|
9
|
+
- "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/usage/paginated-query.md"
|
|
10
|
+
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/composables/query/offsetInfiniteQuery.composable.ts"
|
|
11
|
+
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/composables/query/keysetInfiniteQuery.composable.ts"
|
|
12
|
+
subsystems:
|
|
13
|
+
- "Offset Pagination"
|
|
14
|
+
- "Keyset Pagination"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# @wisemen/vue-core-api-utils — Writing Infinite Queries
|
|
18
|
+
|
|
19
|
+
Paginate through large datasets with two strategies: offset-based (page/limit) for traditional pagination, or keyset-based (cursor) for real-time data and large datasets.
|
|
20
|
+
|
|
21
|
+
**Choose your strategy based on what your backend API provides — not preference.**
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
### Offset Pagination (page-based)
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { ref, computed } from 'vue'
|
|
29
|
+
import { useOffsetInfiniteQuery } from '@/api'
|
|
30
|
+
import { ContactService } from '@/services'
|
|
31
|
+
|
|
32
|
+
export function useContactList() {
|
|
33
|
+
const search = ref('')
|
|
34
|
+
|
|
35
|
+
return useOffsetInfiniteQuery('contactList', {
|
|
36
|
+
params: {
|
|
37
|
+
search: computed(() => search.value),
|
|
38
|
+
},
|
|
39
|
+
queryFn: (pagination) => ContactService.getAll({
|
|
40
|
+
page: pagination.pageParam,
|
|
41
|
+
limit: pagination.limit,
|
|
42
|
+
search: search.value,
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Pagination parameter `pageParam` starts at 0 and increments. Return results with `{ data: Contact[], meta: { page, limit, total } }`.
|
|
49
|
+
|
|
50
|
+
### Keyset Pagination (cursor-based)
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { ref, computed } from 'vue'
|
|
54
|
+
import { useKeysetInfiniteQuery } from '@/api'
|
|
55
|
+
import { ContactService } from '@/services'
|
|
56
|
+
|
|
57
|
+
export function useContactListKeyset() {
|
|
58
|
+
const search = ref('')
|
|
59
|
+
|
|
60
|
+
return useKeysetInfiniteQuery('contactListKeyset', {
|
|
61
|
+
params: {
|
|
62
|
+
search: computed(() => search.value),
|
|
63
|
+
},
|
|
64
|
+
queryFn: (pagination) => ContactService.getAllKeyset({
|
|
65
|
+
limit: pagination.limit,
|
|
66
|
+
cursor: pagination.pageParam,
|
|
67
|
+
search: search.value,
|
|
68
|
+
}),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Pagination parameter `pageParam` is a cursor (string). Return results with `{ data: Contact[], meta: { next?: string } }` — the next cursor or undefined if no more pages.
|
|
74
|
+
|
|
75
|
+
## Core Patterns
|
|
76
|
+
|
|
77
|
+
### Load and display paginated data
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { computed } from 'vue'
|
|
81
|
+
import { useContactList } from '@/composables'
|
|
82
|
+
|
|
83
|
+
const { result, isFetching, fetchNextPage, hasNextPage } = useContactList()
|
|
84
|
+
|
|
85
|
+
const contacts = computed(() => {
|
|
86
|
+
if (result.value.isOk()) {
|
|
87
|
+
return result.value.getValue().data
|
|
88
|
+
}
|
|
89
|
+
return []
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
All pages are automatically concatenated into `data`. Access with `result.getValue().data`.
|
|
94
|
+
|
|
95
|
+
### Load next page
|
|
96
|
+
|
|
97
|
+
```vue
|
|
98
|
+
<button
|
|
99
|
+
@click="fetchNextPage"
|
|
100
|
+
:disabled="isFetchingNextPage || !hasNextPage"
|
|
101
|
+
>
|
|
102
|
+
{{ isFetchingNextPage ? 'Loading...' : 'Load More' }}
|
|
103
|
+
</button>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Use `isFetchingNextPage` (not `isFetching`) to disable the load-more button only during pagination, not during initial load.
|
|
107
|
+
|
|
108
|
+
## Common Mistakes
|
|
109
|
+
|
|
110
|
+
### CRITICAL: Import useInfiniteQuery from @tanstack/vue-query instead of factory
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
// ❌ Wrong: using TanStack directly
|
|
114
|
+
import { useInfiniteQuery } from '@tanstack/vue-query'
|
|
115
|
+
|
|
116
|
+
const { data, error } = useInfiniteQuery({
|
|
117
|
+
queryKey: ['contactList'],
|
|
118
|
+
queryFn: ({ pageParam = 0 }) => ContactService.getAll({ page: pageParam }),
|
|
119
|
+
getNextPageParam: (lastPage) => lastPage.nextPage,
|
|
120
|
+
})
|
|
121
|
+
// Loses AsyncResult, type safety, error codes
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
// ✅ Correct: use factory composable
|
|
126
|
+
import { useOffsetInfiniteQuery } from '@/api'
|
|
127
|
+
|
|
128
|
+
const { result, fetchNextPage, hasNextPage } = useOffsetInfiniteQuery('contactList', {
|
|
129
|
+
params: { search: computed(() => '...') },
|
|
130
|
+
queryFn: (pagination) => ContactService.getAll({
|
|
131
|
+
page: pagination.pageParam,
|
|
132
|
+
limit: pagination.limit,
|
|
133
|
+
}),
|
|
134
|
+
})
|
|
135
|
+
// Full AsyncResult wrapping, type safety, automatic error codes
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Direct TanStack import loses the factory's type safety, AsyncResult wrapping, and error code typing.
|
|
139
|
+
|
|
140
|
+
Source: Library architecture — always use composables from `createApiUtils()` factory
|
|
141
|
+
|
|
142
|
+
### CRITICAL: Return paginated data without wrapping in data/meta structure
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// ❌ Wrong: returning array directly
|
|
146
|
+
queryFn: (pagination) => ContactService.getAll({
|
|
147
|
+
page: pagination.pageParam,
|
|
148
|
+
limit: pagination.limit,
|
|
149
|
+
})
|
|
150
|
+
// Returns Contact[] directly instead of { data: Contact[], meta: {...} }
|
|
151
|
+
// QueryClient doesn't know how to append pages; pages overwrite instead of concat
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// ✅ Correct: return { data, meta } structure
|
|
156
|
+
queryFn: (pagination) => ContactService.getAll({
|
|
157
|
+
page: pagination.pageParam,
|
|
158
|
+
limit: pagination.limit,
|
|
159
|
+
}).then(data => ({
|
|
160
|
+
data,
|
|
161
|
+
meta: {
|
|
162
|
+
page: pagination.pageParam,
|
|
163
|
+
limit: pagination.limit,
|
|
164
|
+
total: 100, // Total count if available
|
|
165
|
+
}
|
|
166
|
+
}))
|
|
167
|
+
// QueryClient knows how to append pages
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Pagination requires the library to know which part of the response is the data array and which part is pagination metadata. Return an object with `data` (array) and `meta` (metadata).
|
|
171
|
+
|
|
172
|
+
Source: `docs/packages/api-utils/pages/usage/paginated-query.md` Handling Pagination Results
|
|
173
|
+
|
|
174
|
+
### HIGH: Mix offset and keyset pagination patterns in same query
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
// ❌ Wrong: mixing pagination patterns
|
|
178
|
+
const { result } = useOffsetInfiniteQuery('contactList', {
|
|
179
|
+
queryFn: (pagination) => ContactService.getAllKeyset({
|
|
180
|
+
cursor: pagination.pageParam, // offset expects page number!
|
|
181
|
+
limit: pagination.limit,
|
|
182
|
+
}),
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// ✅ Correct: match composable to backend API
|
|
188
|
+
// Use useOffsetInfiniteQuery for page/limit APIs:
|
|
189
|
+
const { result } = useOffsetInfiniteQuery('contactList', {
|
|
190
|
+
queryFn: (pagination) => ContactService.getAll({
|
|
191
|
+
page: pagination.pageParam,
|
|
192
|
+
limit: pagination.limit,
|
|
193
|
+
}),
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// Use useKeysetInfiniteQuery for cursor-based APIs:
|
|
197
|
+
const { result } = useKeysetInfiniteQuery('contactList', {
|
|
198
|
+
queryFn: (pagination) => ContactService.getAllKeyset({
|
|
199
|
+
cursor: pagination.pageParam,
|
|
200
|
+
limit: pagination.limit,
|
|
201
|
+
}),
|
|
202
|
+
})
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Each composable expects a specific pagination parameter type. Offset expects a number; keyset expects a cursor string. Choose the right composable for your backend API.
|
|
206
|
+
|
|
207
|
+
Source: `docs/packages/api-utils/pages/usage/paginated-query.md` Offset vs Keyset comparison
|
|
208
|
+
|
|
209
|
+
### MEDIUM: Forget isFetchingNextPage flag; show loading on first page load
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
// ❌ Wrong: using isFetching on load-more button
|
|
213
|
+
const { result, isFetching, fetchNextPage } = useOffsetInfiniteQuery(...)
|
|
214
|
+
<button @click="fetchNextPage" :disabled="isFetching">
|
|
215
|
+
{{ isFetching ? 'Loading...' : 'Load More' }}
|
|
216
|
+
</button>
|
|
217
|
+
// Button disabled on initial load too!
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// ✅ Correct: use isFetchingNextPage for pagination button
|
|
222
|
+
const { result, isFetchingNextPage, fetchNextPage } = useOffsetInfiniteQuery(...)
|
|
223
|
+
<button @click="fetchNextPage" :disabled="isFetchingNextPage">
|
|
224
|
+
{{ isFetchingNextPage ? 'Loading...' : 'Load More' }}
|
|
225
|
+
</button>
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`isFetching` is true during initial load and when fetching next pages. `isFetchingNextPage` is true only when loading additional pages. Use `isFetchingNextPage` for the load-more button.
|
|
229
|
+
|
|
230
|
+
Source: `docs/packages/api-utils/pages/usage/paginated-query.md` Return Values
|
|
231
|
+
|
|
232
|
+
## Backend API Strategy
|
|
233
|
+
|
|
234
|
+
> Offset vs keyset pagination depends entirely on your backend endpoint. Use the strategy your API provides.
|
|
235
|
+
>
|
|
236
|
+
> — Maintainer guidance
|
|
237
|
+
|
|
238
|
+
If your API provides `page` and `limit` parameters, use `useOffsetInfiniteQuery`.
|
|
239
|
+
If your API provides a `cursor` parameter, use `useKeysetInfiniteQuery`.
|
|
240
|
+
|
|
241
|
+
## See Also
|
|
242
|
+
|
|
243
|
+
- [Writing Queries](../writing-queries/SKILL.md) — Infinite queries are queries; all query concepts apply
|