@wisemen/vue-core-api-utils 2.0.0 → 2.0.1

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.
@@ -1,19 +1,14 @@
1
1
  ---
2
2
  name: getting-started
3
3
  description: >
4
- Install @wisemen/vue-core-api-utils, initialize apiUtilsPlugin with QueryClient config, register typed query keys via module augmentation, re-export composables.
4
+ Install @wisemen/vue-core-api-utils, initialize apiUtilsPlugin with QueryClient config, register typed query keys via module augmentation, set up a typed QueryClient wrapper.
5
5
  type: lifecycle
6
6
  library: vue-core-api-utils
7
- library_version: "1.2.0"
8
- sources:
9
- - "wisemen-digital/wisemen-core:packages/web/api-utils/src/plugin/apiUtilsPlugin.ts"
10
- - "wisemen-digital/wisemen-core:packages/web/api-utils/src/register.ts"
11
- - "wisemen-digital/wisemen-core:packages/web/api-utils/src/config/config.ts"
12
7
  ---
13
8
 
14
9
  # @wisemen/vue-core-api-utils — Getting Started
15
10
 
16
- Get `@wisemen/vue-core-api-utils` installed, your Vue Query plugin initialized, query keys defined, and typed composables available.
11
+ Get `@wisemen/vue-core-api-utils` installed, your Vue Query plugin initialized, query keys registered, and a typed QueryClient created.
17
12
 
18
13
  ## Setup
19
14
 
@@ -23,35 +18,37 @@ Get `@wisemen/vue-core-api-utils` installed, your Vue Query plugin initialized,
23
18
  pnpm install @wisemen/vue-core-api-utils @tanstack/vue-query neverthrow vue
24
19
  ```
25
20
 
26
- ### 2. Define your query keys
21
+ ### 2. Register your query keys via module augmentation
27
22
 
28
- Create a TypeScript interface that maps query keys to their response types and parameters:
23
+ Augment the `Register` interface to define your query keys and error codes:
29
24
 
30
25
  ```typescript
31
26
  // src/types/queryKey.type.ts
32
27
 
33
- export interface ProjectQueryKeys {
34
- // Single entity query
35
- contactDetail: {
36
- entity: Contact
37
- params: { contactUuid: string }
38
- }
39
-
40
- // List query with offset pagination
41
- contactList: {
42
- entity: Contact[]
43
- params: { search?: string }
44
- }
45
-
46
- // List query with keyset pagination
47
- contactListKeyset: {
48
- entity: Contact[]
49
- params: { search?: string }
28
+ import type { Contact } from '@/models'
29
+
30
+ declare module '@wisemen/vue-core-api-utils' {
31
+ interface Register {
32
+ queryKeys: {
33
+ contactDetail: {
34
+ entity: Contact
35
+ params: { contactUuid: string }
36
+ }
37
+ contactList: {
38
+ entity: Contact[]
39
+ params: { search?: string }
40
+ }
41
+ contactListKeyset: {
42
+ entity: Contact[]
43
+ params: { search?: string }
44
+ }
45
+ }
46
+ errorCodes: 'NOT_FOUND' | 'UNAUTHORIZED' | 'VALIDATION_ERROR'
50
47
  }
51
48
  }
52
49
  ```
53
50
 
54
- Every key must have both `entity` (response type) and `params` (required parameters).
51
+ Every key must have `entity` (response type) and `params` (filter/identifier parameters). Do not include pagination fields in params — those are handled by the infinite query composables.
55
52
 
56
53
  ### 3. Initialize the plugin in your main.ts
57
54
 
@@ -76,11 +73,9 @@ app.use(apiUtilsPlugin({
76
73
  app.mount('#app')
77
74
  ```
78
75
 
79
- The `apiUtilsPlugin` function creates a QueryClient with your config and handles @tanstack/vue-query setup internally.
76
+ The `apiUtilsPlugin` creates a QueryClient with your config and handles @tanstack/vue-query setup internally.
80
77
 
81
- ### 4. Register your types and re-export composables
82
-
83
- Use module augmentation to register your query keys and error codes for library-wide type safety, then re-export composables for convenient importing across your project:
78
+ ### 4. Create your API module
84
79
 
85
80
  ```typescript
86
81
  // src/api/index.ts
@@ -90,39 +85,35 @@ import type {
90
85
  KeysetPaginationResult as ApiUtilsKeysetPaginationResult,
91
86
  OffsetPaginationResult as ApiUtilsOffsetPaginationResult,
92
87
  } from '@wisemen/vue-core-api-utils'
88
+ import {
89
+ QueryClient,
90
+ getTanstackQueryClient,
91
+ useQuery,
92
+ useMutation,
93
+ useOffsetInfiniteQuery,
94
+ useKeysetInfiniteQuery,
95
+ } from '@wisemen/vue-core-api-utils'
93
96
 
94
- import type { ProjectQueryKeys } from '@/types/queryKey.type'
95
-
96
- // Define your error codes
97
- export type ERROR_KEYS = 'NOT_FOUND' | 'UNAUTHORIZED' | 'NETWORK_ERROR' | 'VALIDATION_ERROR'
98
-
99
- // Register query keys and error codes via module augmentation
100
- // This makes all composables fully typed without a factory function
101
- declare module '@wisemen/vue-core-api-utils' {
102
- interface Register {
103
- queryKeys: ProjectQueryKeys
104
- errorCodes: ERROR_KEYS
105
- }
106
- }
107
-
108
- // Re-export composables for convenient importing
97
+ // Re-export composables for convenience
109
98
  export {
110
- useKeysetInfiniteQuery,
99
+ useQuery,
111
100
  useMutation,
112
101
  useOffsetInfiniteQuery,
113
- useQuery,
114
- usePrefetchKeysetInfiniteQuery,
115
- usePrefetchOffsetInfiniteQuery,
116
- usePrefetchQuery,
117
- } from '@wisemen/vue-core-api-utils'
102
+ useKeysetInfiniteQuery,
103
+ }
104
+
105
+ // Create a typed QueryClient helper
106
+ export function useQueryClient() {
107
+ return new QueryClient(getTanstackQueryClient())
108
+ }
118
109
 
119
110
  // Export typed result types
120
- export type ApiResult<T> = ApiUtilsApiResult<T, ERROR_KEYS>
121
- export type OffsetPaginationResult<T> = ApiUtilsOffsetPaginationResult<T, ERROR_KEYS>
122
- export type KeysetPaginationResult<T> = ApiUtilsKeysetPaginationResult<T, ERROR_KEYS>
111
+ export type ApiResult<T> = ApiUtilsApiResult<T>
112
+ export type OffsetPaginationResult<T> = ApiUtilsOffsetPaginationResult<T>
113
+ export type KeysetPaginationResult<T> = ApiUtilsKeysetPaginationResult<T>
123
114
  ```
124
115
 
125
- The `declare module` block tells the library about your project's types. After this, every composable automatically knows your query keys and error codes.
116
+ Composables are imported directly from the package. The `QueryClient` is a type-safe wrapper around the Tanstack QueryClient its types are inferred from your `Register` augmentation.
126
117
 
127
118
  ## Core Patterns
128
119
 
@@ -156,17 +147,17 @@ import { ContactService } from '@/services'
156
147
 
157
148
  export function useCreateContact() {
158
149
  return useMutation({
159
- queryFn: async (options: { body: ContactCreateForm }) => {
160
- return await ContactService.create(options.body)
150
+ queryFn: async ({ body }: { body: ContactCreateForm }) => {
151
+ return await ContactService.create(body)
161
152
  },
162
153
  queryKeysToInvalidate: {
163
- contactList: {}, // Invalidate all contactList queries after success
154
+ contactList: {},
164
155
  },
165
156
  })
166
157
  }
167
158
  ```
168
159
 
169
- Every mutation should list which queries to invalidate via `queryKeysToInvalidate`.
160
+ Every mutation should list which queries to invalidate via `queryKeysToInvalidate`. An empty object `{}` invalidates all queries with that key.
170
161
 
171
162
  ### Use composables in components
172
163
 
@@ -185,7 +176,7 @@ const { result, refetch } = useContactDetail(props.contactUuid)
185
176
  Name: {{ result.getValue().name }}
186
177
  </div>
187
178
  <div v-else-if="result.isErr()">
188
- Error: {{ result.getError().detail }}
179
+ Error occurred
189
180
  </div>
190
181
  <button @click="refetch">Retry</button>
191
182
  </div>
@@ -194,91 +185,12 @@ const { result, refetch } = useContactDetail(props.contactUuid)
194
185
 
195
186
  All queries and mutations return `AsyncResult` with three states: loading, ok, and err.
196
187
 
197
- ## Common Mistakes
198
-
199
- ### CRITICAL: Forget to initialize apiUtilsPlugin
200
-
201
- ```typescript
202
- // ❌ Wrong: plugin not initialized
203
- const app = createApp(App)
204
- app.mount('#app')
205
- // Throws: "[api-utils] QueryClient not available..."
206
- ```
207
-
208
- ```typescript
209
- // ✅ Correct: plugin initialized with config
210
- const app = createApp(App)
211
- app.use(apiUtilsPlugin({
212
- defaultOptions: {
213
- queries: { staleTime: 1000 * 60 * 5 },
214
- },
215
- }))
216
- app.mount('#app')
217
- ```
218
-
219
- Without the plugin, composables have no QueryClient and throw immediately.
220
-
221
- Source: `src/config/config.ts` — `getQueryClient()` assertion
222
-
223
- ### HIGH: Define query keys interface without strict entity/params structure
224
-
225
- ```typescript
226
- // ❌ Wrong: inconsistent structure
227
- export interface ProjectQueryKeys {
228
- contactDetail: { entity: Contact } // Missing params!
229
- contactList: Contact[] // Should wrap in { entity, params }
230
- }
231
- ```
232
-
233
- ```typescript
234
- // ✅ Correct: every key has entity and params
235
- export interface ProjectQueryKeys {
236
- contactDetail: {
237
- entity: Contact
238
- params: { contactUuid: string }
239
- }
240
- contactList: {
241
- entity: Contact[]
242
- params: { search?: string }
243
- }
244
- }
245
- ```
246
-
247
- Query keys without proper structure cause TypeScript errors and prevent correct query key resolution.
248
-
249
- Source: `src/register.ts` — `RegisteredQueryKeyEntity` and `RegisteredQueryKeyParams` type derivation
250
-
251
- ### HIGH: Skip the module augmentation; composables lose type safety
252
-
253
- ```typescript
254
- // ❌ Wrong: no declare module block in @/api/index.ts
255
- // Composables fall back to `object` for query keys and `string` for error codes
256
- // TypeScript won't catch wrong query key names or invalid error codes
257
- ```
258
-
259
- ```typescript
260
- // ✅ Correct: declare module augments the Register interface
261
- declare module '@wisemen/vue-core-api-utils' {
262
- interface Register {
263
- queryKeys: ProjectQueryKeys
264
- errorCodes: ERROR_KEYS
265
- }
266
- }
267
- // Now useQuery('nonExistentKey', ...) is a compile error
268
- // And error.code is typed as ERROR_KEYS, not just string
269
- ```
270
-
271
- Without the `declare module` block the library types fall back to generic `object` and `string`. All query key checks and error code checks become useless at compile time.
272
-
273
- Source: `src/register.ts`
274
-
275
188
  ## You're all set!
276
189
 
277
190
  You now have:
278
191
  - ✅ Plugin initialized with Vue Query
279
- - ✅ Query keys defined with types
280
- - ✅ Types registered via module augmentation
281
- - ✅ Composables re-exported from `@/api`
192
+ - ✅ Query keys registered via module augmentation
193
+ - ✅ Typed QueryClient wrapper created
282
194
  - ✅ Error codes enumerated
283
195
 
284
196
  Head to [writing-queries](../writing-queries/SKILL.md) to fetch your first resource, or [asyncresult-handling](../asyncresult-handling/SKILL.md) to understand the three-state AsyncResult type.
@@ -1,38 +1,22 @@
1
1
  ---
2
2
  name: optimistic-uis
3
3
  description: >
4
- Combining mutations, QueryClient.update() with built-in rollback, and AsyncResult to create responsive UIs with instant feedback; optimistic updates with automatic error reversal.
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
5
  type: core
6
6
  library: vue-core-api-utils
7
- library_version: "1.2.0"
8
- sources:
9
- - "wisemen-digital/wisemen-core:packages/web/api-utils/src/composables/mutation/mutation.composable.ts"
10
- - "wisemen-digital/wisemen-core:packages/web/api-utils/src/utils/query-client/queryClient.ts"
11
7
  ---
12
8
 
13
9
  # @wisemen/vue-core-api-utils — Optimistic UIs
14
10
 
15
- Create fast, responsive UIs by updating the cache immediately while mutations execute in the background. Combine `useMutation()`, `QueryClient`, and `AsyncResult` pattern matching to provide instant feedback to users.
11
+ 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.
16
12
 
17
13
  ## Setup
18
14
 
19
15
  ```typescript
20
- // src/api/queryClient.ts
21
- import { QueryClient, getTanstackQueryClient } from '@wisemen/vue-core-api-utils'
22
- import type { ProjectQueryKeys } from '@/types/queryKey.type'
23
-
24
- export function useQueryClient() {
25
- return new QueryClient<ProjectQueryKeys>(getTanstackQueryClient())
26
- }
27
- ```
28
-
29
- ```typescript
30
- import { useQuery, useMutation } from '@/api'
31
- import { useQueryClient } from '@/api/queryClient'
16
+ import { useMutation, useQueryClient, useQuery } from '@/api'
32
17
  import { computed } from 'vue'
33
18
 
34
19
  const queryClient = useQueryClient()
35
-
36
20
  const { result: contact } = useQuery('contactDetail', {
37
21
  params: {
38
22
  contactUuid: computed(() => contactUuid),
@@ -40,22 +24,23 @@ const { result: contact } = useQuery('contactDetail', {
40
24
  queryFn: () => ContactService.getDetail(contactUuid),
41
25
  })
42
26
 
43
- const { execute, result: mutationResult } = useMutation({
44
- queryFn: ({ body }) => ContactService.updateContact(contactUuid, body),
27
+ const { execute, isLoading, result: mutationResult } = useMutation({
28
+ queryFn: ({ body }: { body: ContactUpdateForm }) =>
29
+ ContactService.updateContact(contactUuid, body),
45
30
  queryKeysToInvalidate: { contactList: {} },
46
31
  })
47
32
 
48
- async function handleSubmit(formData) {
49
- // Optimistic update — returns rollback function
50
- const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
51
- by: () => true,
33
+ async function handleSubmit(formData: ContactUpdateForm) {
34
+ // Optimistic update — returns { rollback } for reverting on error
35
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
36
+ by: (c) => true,
52
37
  value: (c) => ({ ...c, ...formData }),
53
38
  })
54
39
 
55
40
  // Execute mutation
56
41
  const result = await execute({ body: formData })
57
42
 
58
- // Revert cache on error
43
+ // On error, rollback
59
44
  if (result.isErr()) {
60
45
  rollback()
61
46
  }
@@ -69,59 +54,64 @@ async function handleSubmit(formData) {
69
54
  ```typescript
70
55
  const queryClient = useQueryClient()
71
56
 
72
- const { execute } = useMutation({
73
- queryFn: (data) => ContactService.updateContact(contactUuid, data),
57
+ const { result } = useQuery('contactDetail', {
58
+ params: { contactUuid: computed(() => contactUuid) },
59
+ queryFn: () => ContactService.getDetail(contactUuid),
60
+ })
61
+
62
+ const { execute, isLoading } = useMutation({
63
+ queryFn: ({ body }: { body: ContactUpdateForm }) =>
64
+ ContactService.updateContact(contactUuid, body),
74
65
  queryKeysToInvalidate: { contactList: {} },
75
66
  })
76
67
 
77
- async function handleSave(formData) {
78
- // Cache update happens immediately UI shows new data right away
79
- const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
80
- by: () => true,
68
+ async function handleSave(formData: ContactUpdateForm) {
69
+ // Cache update happens immediately, rollback returned for error case
70
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
71
+ by: (c) => true,
81
72
  value: (c) => ({ ...c, ...formData }),
82
73
  })
83
74
 
84
75
  // Mutation executes in background
85
76
  const result = await execute({ body: formData })
86
77
 
87
- // On error, revert to the previous state
88
78
  if (result.isErr()) {
89
79
  rollback()
90
80
  }
91
81
  }
92
82
  ```
93
83
 
94
- Users see changes instantly. No perceived latency. If the server rejects the change, `rollback()` restores the previous cache state automatically.
84
+ Users see changes instantly. `isLoading` stays true during request, giving visual feedback. No perceived latency.
95
85
 
96
86
  ### Error handling with AsyncResult
97
87
 
98
88
  ```typescript
99
- async function handleSave(formData) {
100
- const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
101
- by: () => true,
89
+ async function handleSave(formData: ContactUpdateForm) {
90
+ const queryClient = useQueryClient()
91
+
92
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
93
+ by: (c) => true,
102
94
  value: (c) => ({ ...c, ...formData }),
103
95
  })
104
96
 
105
97
  const result = await execute({ body: formData })
106
98
 
107
- result.match({
108
- ok: () => {
109
- // Server confirmed the update
110
- // Cache already reflects the change from the optimistic update
111
- showSuccessMessage('Contact updated')
112
- },
113
- err: (error) => {
114
- // Revert optimistic update
115
- rollback()
116
- showErrorMessage(`Failed: ${'errors' in error ? error.errors[0].detail : error.message}`)
117
- },
118
- loading: () => {
119
- // Won't happen after await, but required by match()
120
- },
121
- })
99
+ if (result.isOk()) {
100
+ showSuccessMessage('Contact updated')
101
+ } else if (result.isErr()) {
102
+ rollback()
103
+ const error = result.getError()
104
+ if ('errors' in error) {
105
+ showErrorMessage(`Failed: ${error.errors[0].detail}`)
106
+ } else {
107
+ showErrorMessage('An unexpected error occurred')
108
+ }
109
+ }
122
110
  }
123
111
  ```
124
112
 
113
+ When mutation fails, call `rollback()` to revert the optimistic cache change. Narrow the error type with `'errors' in error` to distinguish expected API errors from unexpected ones.
114
+
125
115
  ### Composable combining query + mutation + optimistic UI
126
116
 
127
117
  ```typescript
@@ -129,28 +119,26 @@ export function useContactEditor(contactUuid: string) {
129
119
  const queryClient = useQueryClient()
130
120
 
131
121
  const { result: contact } = useQuery('contactDetail', {
132
- params: computed(() => ({ contactUuid })),
122
+ params: { contactUuid: computed(() => contactUuid) },
133
123
  queryFn: () => ContactService.getDetail(contactUuid),
134
124
  })
135
125
 
136
- const { execute, result: mutationResult } = useMutation({
137
- queryFn: ({ body }) => ContactService.updateContact(contactUuid, body),
126
+ const { execute, isLoading, result: mutationResult } = useMutation({
127
+ queryFn: ({ body }: { body: ContactUpdateForm }) =>
128
+ ContactService.updateContact(contactUuid, body),
138
129
  queryKeysToInvalidate: {
139
130
  contactList: {},
140
131
  },
141
132
  })
142
133
 
143
- async function saveContact(formData) {
144
- // Optimistic update
145
- const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
134
+ async function saveContact(formData: ContactUpdateForm) {
135
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
146
136
  by: () => true,
147
137
  value: (c) => ({ ...c, ...formData }),
148
138
  })
149
139
 
150
- // Execute
151
140
  const result = await execute({ body: formData })
152
141
 
153
- // Rollback on error
154
142
  if (result.isErr()) {
155
143
  rollback()
156
144
  }
@@ -161,6 +149,7 @@ export function useContactEditor(contactUuid: string) {
161
149
  return {
162
150
  contact,
163
151
  saveContact,
152
+ isLoading,
164
153
  mutationResult,
165
154
  }
166
155
  }
@@ -168,175 +157,22 @@ export function useContactEditor(contactUuid: string) {
168
157
 
169
158
  Encapsulate the full flow in a composable for reusability across components.
170
159
 
171
- ## Common Mistakes
172
-
173
- ### HIGH: Ignore the rollback return from update(); can't revert on error
174
-
175
- ```typescript
176
- // ❌ Wrong: discarding rollback
177
- async function handleSave(formData) {
178
- queryClient.update(['contactDetail', { contactUuid }] as const, {
179
- by: () => true,
180
- value: (c) => ({ ...c, ...formData }),
181
- })
182
- // rollback discarded!
183
-
184
- const result = await execute({ body: formData })
185
-
186
- if (result.isErr()) {
187
- // No way to undo the optimistic change!
188
- showErrorMessage('Save failed')
189
- // UI now shows wrong data permanently
190
- }
191
- }
192
- ```
193
-
194
- ```typescript
195
- // ✅ Correct: capture and use rollback
196
- async function handleSave(formData) {
197
- const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
198
- by: () => true,
199
- value: (c) => ({ ...c, ...formData }),
200
- })
201
-
202
- const result = await execute({ body: formData })
203
-
204
- if (result.isErr()) {
205
- rollback() // Restores previous state automatically
206
- showErrorMessage('Save failed, changes reverted')
207
- }
208
- }
209
- ```
210
-
211
- `update()` captures the previous state internally and exposes it via `rollback()`. Always capture and use it when doing optimistic updates.
212
-
213
- Source: `src/utils/query-client/queryClient.ts` — `QueryClientUpdateResult`
214
-
215
- ### CRITICAL: Stale optimistic update if component unmounts during pending mutation
216
-
217
- ```typescript
218
- // ❌ Wrong: mutation pending, user navigates away, rollback never called
219
- async function handleSave(formData) {
220
- const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
221
- by: () => true,
222
- value: (c) => ({ ...c, ...formData }),
223
- })
224
-
225
- // User navigates away while mutation is pending
226
- // Cache still has optimistic data
227
- const result = await execute({ body: formData })
228
- if (result.isErr()) {
229
- rollback() // User is gone — rollback still works but user won't see it
230
- }
231
- }
232
- ```
233
-
234
- ```typescript
235
- // ✅ Correct: rollback on unmount if mutation is still in flight
236
- import { onUnmounted } from 'vue'
237
-
238
- let pendingRollback: (() => void) | null = null
239
-
240
- async function handleSave(formData) {
241
- const { rollback } = queryClient.update(['contactDetail', { contactUuid }] as const, {
242
- by: () => true,
243
- value: (c) => ({ ...c, ...formData }),
244
- })
245
- pendingRollback = rollback
246
-
247
- const result = await execute({ body: formData })
248
- pendingRollback = null
249
-
250
- if (result.isErr()) {
251
- rollback()
252
- }
253
- }
254
-
255
- onUnmounted(() => {
256
- // If still saving and user leaves, rollback stale optimistic update
257
- pendingRollback?.()
258
- })
259
- ```
260
-
261
- If the user navigates away while a mutation is pending, rollback the optimistic change using component lifecycle hooks. Otherwise, stale unconfirmed data persists in the global cache.
262
-
263
- Source: Architectural consideration from cache invalidation patterns
160
+ ## Rollback Strategy
264
161
 
265
- ### HIGH: Optimistic update on list query with wrong predicate; patches wrong item
162
+ `queryClient.update()` returns a `{ rollback }` function that reverts the cache to its previous state:
266
163
 
267
164
  ```typescript
268
- // Wrong: update without proper predicate
269
- queryClient.update('contactList', {
270
- by: () => true, // Matches ALL contacts in the list!
271
- value: (c) => ({ ...c, name: 'Updated' }),
165
+ const { rollback } = queryClient.update(['contactDetail', { contactUuid }], {
166
+ by: () => true,
167
+ value: (c) => ({ ...c, ...formData }),
272
168
  })
273
- // Every contact in the cached list gets "Updated" as name
274
- ```
275
-
276
- ```typescript
277
- // ✅ Correct: use a specific predicate
278
- queryClient.update('contactList', {
279
- by: (contact) => contact.uuid === contactUuid, // Only match the specific item
280
- value: (contact) => ({ ...contact, name: formData.name }),
281
- })
282
- ```
283
-
284
- When updating lists, always use a predicate that uniquely identifies the item you're changing. `by: () => true` is only appropriate for single-entity queries.
285
-
286
- Source: `src/utils/query-client/queryClient.ts` — `updateEntity` iterates arrays
287
-
288
- ### MEDIUM: Race condition: multiple mutations affect the same query
289
-
290
- ```typescript
291
- // ❌ Wrong: two mutations in flight affecting the same cache
292
- async function handleMultipleSaves() {
293
- // Mutation 1 optimistic update
294
- const { rollback: rollback1 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
295
- by: () => true,
296
- value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
297
- })
298
- // Don't await — start Mutation 2 immediately!
299
- execute1({ body: { tags: [...contact.tags, 'new-tag'] } })
300
-
301
- // Mutation 2 on same cache entry
302
- const { rollback: rollback2 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
303
- by: () => true,
304
- value: (c) => ({ ...c, name: 'Updated' }),
305
- })
306
- await execute2({ body: { name: 'Updated' } })
307
-
308
- // If Mutation 2 completes before Mutation 1, the tags may be lost
309
- }
310
- ```
311
-
312
- ```typescript
313
- // ✅ Correct: serialize mutations to avoid ordering issues
314
- async function handleMultipleSaves() {
315
- const { rollback: rollback1 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
316
- by: () => true,
317
- value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
318
- })
319
- const result1 = await execute1({ body: { tags: [...contact.tags, 'new-tag'] } })
320
- if (result1.isErr()) {
321
- rollback1()
322
- return
323
- }
324
-
325
- // Only start Mutation 2 after Mutation 1 finishes
326
- const { rollback: rollback2 } = queryClient.update(['contactDetail', { contactUuid }] as const, {
327
- by: () => true,
328
- value: (c) => ({ ...c, name: 'Updated' }),
329
- })
330
- const result2 = await execute2({ body: { name: 'Updated' } })
331
- if (result2.isErr()) {
332
- rollback2()
333
- }
169
+ const result = await execute({ body: formData })
170
+ if (result.isErr()) {
171
+ rollback()
334
172
  }
335
173
  ```
336
174
 
337
- Serialize mutations that affect the same cache entry. Concurrent mutations on the same key can lead to lost updates or incorrect rollbacks.
338
-
339
- Source: Architectural consideration from query lifecycle patterns
175
+ Always use the built-in `rollback()` rather than manually saving and restoring the original data it handles all edge cases including list updates and concurrent modifications.
340
176
 
341
177
  ## See Also
342
178