@wisemen/vue-core-api-utils 1.0.0 → 1.1.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.
@@ -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