Skip to main content

Optimistic Updates

Optimistic updates allow you to update the UI immediately before the server responds, providing a better user experience. If the mutation fails, you can rollback the changes.

Using the Helper

Experimental API

The createOptimisticUpdate and createMultiOptimisticUpdate helpers are experimental and may change in future versions. For production applications, we recommend using the manual pattern below until these helpers stabilize.

The createOptimisticUpdate helper simplifies the optimistic update pattern by handling the boilerplate:

import { createOptimisticUpdate } from '@navios/react-query'

const updateUser = client.mutation({
method: 'PUT',
url: '/users/$userId',
requestSchema: userUpdateSchema,
responseSchema: userSchema,
processResponse: (data) => data,
useContext: () => ({
queryClient: useQueryClient(),
}),
...createOptimisticUpdate({
queryKey: ['users', userId],
updateFn: (oldData, variables) => ({
...oldData,
...variables.data,
}),
}),
})

Configuration Options

OptionTypeDefaultDescription
queryKeyreadonly unknown[]RequiredThe query key to optimistically update
updateFn(oldData, variables) => newDataRequiredFunction to compute the new cache value
rollbackOnErrorbooleantrueWhether to rollback on error
invalidateOnSettledbooleantrueWhether to invalidate the query after mutation settles

Multi-Query Updates

Experimental API

createMultiOptimisticUpdate is experimental and may change in future versions.

When a mutation affects multiple cached queries, use createMultiOptimisticUpdate:

import { createMultiOptimisticUpdate } from '@navios/react-query'

const updateUser = client.mutation({
method: 'PUT',
url: '/users/$userId',
requestSchema: userUpdateSchema,
responseSchema: userSchema,
processResponse: (data) => data,
useContext: () => ({
queryClient: useQueryClient(),
}),
...createMultiOptimisticUpdate([
{
// Update individual user cache
queryKey: ['users', userId],
updateFn: (oldData, variables) => ({
...oldData,
...variables.data,
}),
},
{
// Update user in the list
queryKey: ['users'],
updateFn: (oldList, variables) =>
(oldList ?? []).map((user) =>
user.id === userId ? { ...user, ...variables.data } : user
),
},
]),
})

Common Patterns with Helpers

Optimistic Delete:

const deleteTodo = client.mutation({
method: 'DELETE',
url: '/todos/$todoId',
responseSchema: z.object({ success: z.boolean() }),
processResponse: (data) => data,
useContext: () => ({ queryClient: useQueryClient() }),
...createOptimisticUpdate({
queryKey: ['todos'],
updateFn: (oldData, variables) =>
(oldData ?? []).filter((t) => t.id !== variables.urlParams.todoId),
}),
})

Optimistic Add:

const addTodo = client.mutation({
method: 'POST',
url: '/todos',
requestSchema: createTodoSchema,
responseSchema: todoSchema,
processResponse: (data) => data,
useContext: () => ({ queryClient: useQueryClient() }),
...createOptimisticUpdate({
queryKey: ['todos'],
updateFn: (oldData, variables) => [
...(oldData ?? []),
{ id: 'temp-' + Date.now(), ...variables.data, createdAt: new Date() },
],
}),
})

For production applications, we recommend implementing the optimistic update pattern manually. This gives you full control and doesn't depend on experimental APIs.

Pattern Overview

The manual optimistic update pattern involves:

  1. onMutate: Cancel queries, snapshot previous value, optimistically update
  2. onError: Rollback to previous value
  3. onSuccess/onSettled: Refetch to ensure consistency

Manual Example

const updateUser = client.mutation({
method: 'PUT',
url: '/users/$userId',
requestSchema: userUpdateSchema,
responseSchema: userSchema,
processResponse: (data) => data,
useContext: () => ({
queryClient: useQueryClient(),
}),
onMutate: async (variables, context) => {
// 1. Cancel outgoing queries
await context.queryClient.cancelQueries({
queryKey: getUser.queryKey.filterKey({
urlParams: { userId: variables.urlParams.userId },
}),
})

// 2. Snapshot previous value
const previous = context.queryClient.getQueryData(
getUser.queryKey.dataTag({
urlParams: { userId: variables.urlParams.userId },
})
)

// 3. Optimistically update
context.queryClient.setQueryData(
getUser.queryKey.dataTag({
urlParams: { userId: variables.urlParams.userId },
}),
{ ...previous, ...variables.data }
)

// 4. Return context for rollback
return { previous }
},
onError: (error, variables, context) => {
// Rollback on error
if (context.onMutateResult?.previous) {
context.queryClient.setQueryData(
getUser.queryKey.dataTag({
urlParams: { userId: variables.urlParams.userId },
}),
context.onMutateResult.previous
)
}
},
onSuccess: (data, variables, context) => {
// Optionally refetch to ensure consistency
context.queryClient.invalidateQueries({
queryKey: getUser.queryKey.filterKey({
urlParams: { userId: data.id },
}),
})
},
})

Updating Lists

Optimistically update items in a list:

const updateUser = client.mutation({
method: 'PUT',
url: '/users/$userId',
requestSchema: userUpdateSchema,
responseSchema: userSchema,
processResponse: (data) => data,
useContext: () => ({
queryClient: useQueryClient(),
}),
onMutate: async (variables, context) => {
await context.queryClient.cancelQueries({
queryKey: getUsers.queryKey.filterKey({}),
})

const previous = context.queryClient.getQueryData(
getUsers.queryKey.dataTag({})
)

// Update item in list
context.queryClient.setQueryData(
getUsers.queryKey.dataTag({}),
(old: { users: User[] } | undefined) => {
if (!old) return old
return {
...old,
users: old.users.map((user) =>
user.id === variables.urlParams.userId
? { ...user, ...variables.data }
: user
),
}
}
)

return { previous }
},
onError: (error, variables, context) => {
if (context.onMutateResult?.previous) {
context.queryClient.setQueryData(
getUsers.queryKey.dataTag({}),
context.onMutateResult.previous
)
}
},
})

Adding to Lists

Optimistically add items to a list:

const createUser = client.mutation({
method: 'POST',
url: '/users',
requestSchema: userCreateSchema,
responseSchema: userSchema,
processResponse: (data) => data,
useContext: () => ({
queryClient: useQueryClient(),
}),
onMutate: async (variables, context) => {
await context.queryClient.cancelQueries({
queryKey: getUsers.queryKey.filterKey({}),
})

const previous = context.queryClient.getQueryData(
getUsers.queryKey.dataTag({})
)

// Optimistically add new user
const optimisticUser = {
id: 'temp-' + Date.now(),
...variables.data,
createdAt: new Date().toISOString(),
}

context.queryClient.setQueryData(
getUsers.queryKey.dataTag({}),
(old: { users: User[] } | undefined) => {
if (!old) return { users: [optimisticUser] }
return {
...old,
users: [optimisticUser, ...old.users],
}
}
)

return { previous, optimisticUser }
},
onError: (error, variables, context) => {
if (context.onMutateResult?.previous) {
context.queryClient.setQueryData(
getUsers.queryKey.dataTag({}),
context.onMutateResult.previous
)
}
},
onSuccess: (data, variables, context) => {
// Replace optimistic user with real data
context.queryClient.setQueryData(
getUsers.queryKey.dataTag({}),
(old: { users: User[] } | undefined) => {
if (!old) return old
return {
...old,
users: old.users.map((user) =>
user.id === context.onMutateResult?.optimisticUser.id
? data
: user
),
}
}
)
},
})

Removing from Lists

Optimistically remove items from a list:

const deleteUser = client.mutation({
method: 'DELETE',
url: '/users/$userId',
responseSchema: z.object({ success: z.boolean() }),
processResponse: (data) => data,
useContext: () => ({
queryClient: useQueryClient(),
}),
onMutate: async (variables, context) => {
await context.queryClient.cancelQueries({
queryKey: getUsers.queryKey.filterKey({}),
})

const previous = context.queryClient.getQueryData(
getUsers.queryKey.dataTag({})
)

// Optimistically remove user
context.queryClient.setQueryData(
getUsers.queryKey.dataTag({}),
(old: { users: User[] } | undefined) => {
if (!old) return old
return {
...old,
users: old.users.filter(
(user) => user.id !== variables.urlParams.userId
),
}
}
)

return { previous }
},
onError: (error, variables, context) => {
if (context.onMutateResult?.previous) {
context.queryClient.setQueryData(
getUsers.queryKey.dataTag({}),
context.onMutateResult.previous
)
}
},
})

Best Practices

Always Cancel Queries

// ✅ Good - cancel queries first
onMutate: async (variables, context) => {
await context.queryClient.cancelQueries({ queryKey: ['users'] })
// ... rest of logic
}

// ❌ Bad - may cause race conditions
onMutate: async (variables, context) => {
// Missing cancelQueries
context.queryClient.setQueryData(/* ... */)
}

Snapshot Previous Value

// ✅ Good - save previous for rollback
onMutate: async (variables, context) => {
const previous = context.queryClient.getQueryData(/* ... */)
// ... update
return { previous }
}

// ❌ Bad - can't rollback
onMutate: async (variables, context) => {
context.queryClient.setQueryData(/* ... */)
// No previous value saved
}

Rollback on Error

// ✅ Good - rollback on error
onError: (error, variables, context) => {
if (context.onMutateResult?.previous) {
context.queryClient.setQueryData(/* ... */, context.onMutateResult.previous)
}
}

// ❌ Bad - no rollback
onError: (error, variables, context) => {
// UI stays in optimistic state even on error
}

Next Steps