@wisemen/vue-core-api-utils
Version:
403 lines (320 loc) • 12.7 kB
Markdown
---
name: optimistic-uis
description: >
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.
type: core
library: vue-core-api-utils
library_version: "0.0.3"
sources:
- "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/usage/mutation.md"
- "wisemen-digital/wisemen-core:docs/packages/api-utils/pages/usage/query-client.md"
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/composables/mutation/mutation.composable.ts"
- "wisemen-digital/wisemen-core:packages/web/api-utils/src/utils/query-client/queryClient.ts"
---
# @wisemen/vue-core-api-utils — Optimistic UIs
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.
## Setup
```typescript
import { useMutation, useQueryClient, useQuery } from '@/api'
import { computed } from 'vue'
const queryClient = useQueryClient()
const { result: contact } = useQuery('contactDetail', {
params: {
contactUuid: computed(() => contactUuid),
},
queryFn: () => ContactService.getDetail(contactUuid),
})
const { execute, isLoading, result: mutationResult } = useMutation({
queryFn: ({ body }) => ContactService.updateContact(contactUuid, body),
queryKeysToInvalidate: { contactList: {} },
})
async function handleSubmit(formData) {
// Save original (for rollback)
const originalContact = contact.value.isOk() ? contact.value.getValue() : null
// Optimistic update: immediate cache change
queryClient.update(['contactDetail', { contactUuid }], {
by: (c) => true,
value: (c) => ({ ...c, ...formData }),
})
// Execute mutation
const result = await execute(formData)
// On error, rollback
if (result.isErr()) {
queryClient.set(['contactDetail', { contactUuid }], originalContact)
}
}
```
## Core Patterns
### Immediate cache update while request pending
```typescript
const { result } = useQuery('contactDetail', {
params: computed(() => ({ contactUuid })),
queryFn: () => ContactService.getDetail(contactUuid),
})
const { execute, isLoading } = useMutation({
queryFn: (data) => ContactService.updateContact(contactUuid, data),
queryKeysToInvalidate: { /* ... */ },
})
async function handleSave(formData) {
// Cache update happens immediately
queryClient.update(['contactDetail', { contactUuid }], {
by: (c) => true,
value: (c) => ({ ...c, ...formData }),
})
// Mutation executes in background
await execute(formData)
// UI shows updated data from cache right away
// isLoading is true while request pending
// result.value changes when mutation completes
}
```
Users see changes instantly. `isLoading` stays true during request, giving visual feedback. No perceived latency.
### Error handling with AsyncResult
```typescript
async function handleSave(formData) {
const originalContact = contact.value?.getValue()
queryClient.update(['contactDetail', { contactUuid }], {
by: (c) => true,
value: (c) => ({ ...c, ...formData }),
})
const result = await execute(formData)
// Match on mutation result
result.match({
ok: () => {
// Server confirmed the update
// Cache already reflects the change
showSuccessMessage('Contact updated')
},
err: (error) => {
// Revert optimistic update
queryClient.set(['contactDetail', { contactUuid }], originalContact)
showErrorMessage(`Failed: ${error.message}`)
},
loading: () => {
// Should not happen after await, but handle just in case
},
})
}
```
When mutation fails, revert the optimistic change using the saved original value.
### Composable combining query + mutation + optimistic UI
```typescript
export function useContactEditor(contactUuid) {
const queryClient = useQueryClient()
const { result: contact } = useQuery('contactDetail', {
params: computed(() => ({ contactUuid })),
queryFn: () => ContactService.getDetail(contactUuid),
})
const { execute, isLoading, result: mutationResult } = useMutation({
queryFn: (data) => ContactService.updateContact(contactUuid, data),
queryKeysToInvalidate: {
contactList: () => true,
'contact-stats': () => true,
},
})
async function saveContact(formData) {
const original = contact.value?.getValue()
// Optimistic
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, ...formData }),
})
// Execute
const result = await execute(formData)
// Rollback on error
if (result.isErr()) {
queryClient.set(['contactDetail', { contactUuid }], original)
}
return result
}
return {
contact,
saveContact,
isLoading,
mutationResult,
}
}
```
Encapsulate the full flow in a composable for reusability across components.
## Common Mistakes
### HIGH: Forget to save original data before optimistic update; can't rollback on error
```typescript
// ❌ Wrong: no original saved, rollback impossible
const { execute, result } = useMutation({
queryFn: (data) => ContactService.updateContact(data),
queryKeysToInvalidate: { contactList: () => true },
})
async function handleSave(formData) {
// Update cache immediately
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, ...formData }),
})
const result = await execute(formData)
if (result.isErr()) {
// No way to undo the optimistic change!
showErrorMessage('Save failed')
}
}
```
```typescript
// ✅ Correct: save original before update
async function handleSave(formData) {
// Save original FIRST
const originalContact = contact.value?.getValue()
// Update cache
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, ...formData }),
})
const result = await execute(formData)
if (result.isErr()) {
// Restore original
queryClient.set(['contactDetail', { contactUuid }], originalContact)
showErrorMessage('Save failed, changes reverted')
}
}
```
Always save the current value before modifying the cache. Use it to rollback if the mutation fails.
Source: `docs/packages/api-utils/pages/usage/query-client.md` Real-World Example
### CRITICAL: Handle stale optimistic updates; if component unmounts, optimistic change remains in cache
```typescript
// ❌ Wrong: optimistic update lives in cache even if user navigates away
async function handleSave(formData) {
const originalContact = contact.value?.getValue()
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, ...formData }),
})
// User navigates away while mutation pending
// Cache still has optimistic data
// When user returns, data is stale
const result = await execute(formData)
if (result.isErr()) {
// Rollback happens but user is gone
queryClient.set(['contactDetail', { contactUuid }], originalContact)
}
}
```
```typescript
// ✅ Correct: clear optimistic flag or track mutation completion
import { onUnmounted } from 'vue'
let originalContact = null
let isSaving = false
async function handleSave(formData) {
originalContact = contact.value?.getValue()
isSaving = true
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, ...formData }),
})
const result = await execute(formData)
isSaving = false
if (result.isErr() && originalContact) {
queryClient.set(['contactDetail', { contactUuid }], originalContact)
}
}
onUnmounted(() => {
// If still saving and user leaves, rollback
if (isSaving && originalContact) {
queryClient.set(['contactDetail', { contactUuid }], originalContact)
}
})
```
If user navigates away while a mutation is pending, rollback the optimistic change using component lifecycle hooks. Otherwise, stale data persists in the cache.
Source: Architectural consideration from cache invalidation patterns
### HIGH: Miss querying the correct data structure; optimistic update patches wrong field
```typescript
// ❌ Wrong: update assuming flat entity, but query returns paginated structure
const { result: paginatedContacts } = useQuery('contactList', {
params: computed(() => ({ limit: 20, offset: 0 })),
queryFn: () => ContactService.list({ limit: 20, offset: 0 }),
})
queryClient.update('contactList', {
by: (contact) => contact.id === '123',
value: (contact) => ({ ...contact, name: 'Updated' }),
})
// This fails because contactList is not Contact[] but { data: Contact[], meta: {...} }
```
```typescript
// ✅ Correct: QueryClient handles nested structures automatically
const { result: paginatedContacts } = useQuery('contactList', {
params: computed(() => ({ limit: 20, offset: 0 })),
queryFn: () => ContactService.list({ limit: 20, offset: 0 }),
})
// QueryClient infers the data structure from query keys
// If contactList query returns { data: Contact[], meta: {...} }
// QueryClient knows to iterate contact.data and apply the predicate
queryClient.update('contactList', {
by: (contact) => contact.id === '123',
value: (contact) => ({ ...contact, name: 'Updated' }),
})
```
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.
Source: `packages/web/api-utils/src/utils/query-client/queryClient.ts`
### MEDIUM: Race condition: multiple mutations affect the same query, order matters
```typescript
// ❌ Wrong: two mutations in flight affecting the same cache
async function handleMultipleSaves() {
// Mutation 1: add tag
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
})
const result1 = await execute({ tags: [...contact.value.tags, 'new-tag'] })
// Mutation 2: change name
// But Mutation 1 is still in flight!
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, name: 'Updated' }),
})
const result2 = await execute({ name: 'Updated' })
// If Mutation 2 completes before Mutation 1, the tags are lost
}
```
```typescript
// ✅ Correct: serialize mutations or track multiple originals
async function handleMultipleSaves() {
const original = contact.value?.getValue()
// Either: await each mutation before starting the next
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, tags: [...c.tags, 'new-tag'] }),
})
const result1 = await execute({ tags: [...contact.value.tags, 'new-tag'] })
if (result1.isErr()) {
queryClient.set(['contactDetail', { contactUuid }], original)
return
}
// Now safe to do second mutation
const updatedOriginal = queryClient.get(['contactDetail', { contactUuid }])
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, name: 'Updated' }),
})
const result2 = await execute({ name: 'Updated' })
if (result2.isErr()) {
queryClient.set(['contactDetail', { contactUuid }], updatedOriginal)
}
}
```
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.
Source: Architectural consideration from query lifecycle patterns
## Rollback Strategy
Rollback is enabled by saving the original data before the optimistic update:
```typescript
const original = contact.value?.getValue()
queryClient.update(['contactDetail', { contactUuid }], {
by: () => true,
value: (c) => ({ ...c, ...formData }),
})
const result = await execute(formData)
if (result.isErr()) {
queryClient.set(['contactDetail', { contactUuid }], original)
}
```
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.
## See Also
- [Writing Mutations](../writing-mutations/SKILL.md) — The `execute()` and result handling that pairs with optimistic updates
- [Cache Management](../cache-management/SKILL.md) — QueryClient methods for reading and updating cache
- [Writing Queries](../writing-queries/SKILL.md) — Understanding query results and caching behavior