A tiny, type-safe toolkit that eliminates boilerplate for optimistic UI updates using TanStack Query
npm install @meetdhanani/optimistic-ui> A tiny, type-safe toolkit that eliminates boilerplate for optimistic UI updates using TanStack Query.

Optimistic UI is a UX pattern where the UI updates immediately when a user performs an action, before the server confirms the change. This creates a snappy, responsive feel that makes applications feel instant and modern.
For example, when a user toggles a todo item as complete:
- Without optimistic UI: The checkbox waits for the server response (200-500ms delay) before updating
- With optimistic UI: The checkbox updates instantly, and if the server request fails, it automatically reverts
While TanStack Query (React Query) is excellent for data fetching, implementing optimistic updates requires writing a lot of repetitive boilerplate code. For every mutation, you need to:
1. Cancel in-flight queries to prevent race conditions
2. Snapshot previous data for rollback on errors
3. Generate temporary IDs for new items (and replace them later)
4. Handle cache updates for different data structures (arrays, infinite queries, etc.)
5. Implement error rollback logic
6. Manage edge cases like concurrent mutations, empty caches, and SSR
This results in ~150 lines of boilerplate code per mutation, which is:
- β Repetitive and error-prone
- β Hard to maintain across multiple mutations
- β Easy to forget edge cases
- β Difficult to get right with infinite queries and pagination
I built optimistic-ui because I found myself writing the same optimistic update logic over and over again across multiple projects. The pattern was always the same, but implementing it correctly required:
- Handling temporary IDs that get replaced by server IDs
- Extracting arrays from infinite query page structures
- Preserving data structure integrity
- Managing rollback scenarios
- Supporting both flat arrays and paginated data
Instead of copying and pasting 150 lines of code for each mutation, you can now use a simple hook or function that handles all of this automatically. The library:
- β
Eliminates 90% of boilerplate - From 150 lines to just 5 lines
- β
Handles all edge cases - Works with arrays, infinite queries, and custom ID getters
- β
Type-safe - Full TypeScript support with excellent autocomplete
- β
Battle-tested - Handles concurrent mutations, SSR, and error scenarios
- β
Zero configuration - Works out of the box with sensible defaults
- β
Create / Update / Delete - Full CRUD support with optimistic updates
- π Automatic Rollback - Errors automatically revert optimistic changes
- β©οΈ Undo Support - Built-in undo functionality for deletions
- βΎοΈ Pagination & Infinite Queries - Works seamlessly with useInfiniteQuery
- π‘οΈ SSR Safe - Handles server-side rendering correctly
- π¦ Type-Safe - Full TypeScript support with excellent DX
- π― Zero Boilerplate - Eliminates repetitive optimistic update code
This repository includes working examples to help you get started:
π View Live Demo
``bashInstall dependencies (from root)
pnpm install
Important: This monorepo uses pnpm workspaces. You must use
pnpm, not npm or yarn. The workspace:* protocol in package.json is a pnpm feature.What the React example shows:
- β
Optimistic create (items appear immediately)
- β
Optimistic update (changes apply immediately)
- β
Optimistic delete (items disappear immediately)
- β
Error handling and rollback
$3
π View Live Demo
`bash
cd examples/infinite-query
pnpm install
pnpm dev
`What the infinite query example shows:
- β
Optimistic updates with paginated data
- β
Real API integration (JSONPlaceholder)
- β
Handling object-based page structures
- β
Error simulation and rollback
Installation
`bash
npm i @meetdhanani/optimistic-ui @tanstack/react-query
or
pnpm add @meetdhanani/optimistic-ui @tanstack/react-query
or
yarn add @meetdhanani/optimistic-ui @tanstack/react-query
`Quick Start
$3
`tsx
import {
useOptimisticCreate,
useOptimisticUpdate,
useOptimisticDelete,
useOptimisticDeleteWithUndo
} from '@meetdhanani/optimistic-ui';function TodoList() {
// Create
const createMutation = useOptimisticCreate({
queryKey: ['todos'],
newItem: { title: 'New Todo', completed: false },
mutationFn: createTodo,
});
// Update
const updateMutation = useOptimisticUpdate({
queryKey: ['todos'],
id: todoId,
updater: (todo) => ({ ...todo, completed: !todo.completed }),
mutationFn: updateTodo,
});
// Delete
const deleteMutation = useOptimisticDelete({
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
});
// Delete with Undo
const deleteWithUndoMutation = useOptimisticDeleteWithUndo({
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
undoTimeout: 5000,
});
return (
// Your UI here
);
}
`$3
`tsx
import { useMutation } from '@tanstack/react-query';
import {
optimisticCreate,
optimisticUpdate,
optimisticDelete,
optimisticDeleteWithUndo
} from '@meetdhanani/optimistic-ui';function TodoList() {
// Create
const createMutation = useMutation(
optimisticCreate({
queryKey: ['todos'],
newItem: { title: 'New Todo', completed: false },
mutationFn: createTodo,
})
);
// Update
const updateMutation = useMutation(
optimisticUpdate({
queryKey: ['todos'],
id: todoId,
updater: (todo) => ({ ...todo, completed: !todo.completed }),
mutationFn: updateTodo,
})
);
// Delete
const deleteMutation = useMutation(
optimisticDelete({
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
})
);
// Delete with Undo
const deleteWithUndoMutation = useMutation(
optimisticDeleteWithUndo({
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
undoTimeout: 5000,
})
);
return (
// Your UI here
);
}
`Note: For use outside React components or when you have explicit access to QueryClient, use the
*WithClient variants:
- optimisticCreateWithClient(queryClient, options)
- optimisticUpdateWithClient(queryClient, options)
- optimisticDeleteWithClient(queryClient, options)
- optimisticDeleteWithUndoWithClient(queryClient, options)Note: The hook-based API (
useOptimisticCreate, etc.) is recommended as it provides more reliable QueryClient access. The function-based API works but requires QueryClientProvider context.API Reference
$3
Hooks (Recommended):
-
useOptimisticCreate - Create items optimistically
- useOptimisticUpdate - Update items optimistically
- useOptimisticDelete - Delete items optimistically
- useOptimisticDeleteWithUndo - Delete items with undo supportFunctions (For use with
useMutation or outside React):
- optimisticCreate / optimisticCreateWithClient - Create items optimistically
- optimisticUpdate / optimisticUpdateWithClient - Update items optimistically
- optimisticDelete / optimisticDeleteWithClient - Delete items optimistically
- optimisticDeleteWithUndo / optimisticDeleteWithUndoWithClient - Delete items with undo
- restoreDeletedItem - Helper to restore deleted items (for undo functionality)
$3
The hook-based API is recommended as it provides more reliable QueryClient access through React context.
####
useOptimisticCreateCreates a new item optimistically. Handles temporary IDs that get replaced by server IDs.
`tsx
const mutation = useOptimisticCreate({
queryKey: ['todos'],
newItem: { title: 'New Todo', completed: false },
mutationFn: createTodo,
getId: (item) => item.id, // Optional, defaults to item.id
});
`Options:
-
queryKey: QueryKey - The query key to update
- newItem: T - The new item to add optimistically
- mutationFn: (item: T) => Promise - Function that creates the item on the server
- getId?: (item: T) => string | number - Optional function to extract ID (defaults to item.id)Returns:
UseMutationResult####
useOptimisticUpdateUpdates an existing item optimistically.
`tsx
const mutation = useOptimisticUpdate({
queryKey: ['todos'],
id: todoId,
updater: (todo) => ({ ...todo, completed: !todo.completed }),
mutationFn: updateTodo,
getId: (item) => item.id, // Optional
});
`Options:
-
queryKey: QueryKey - The query key to update
- id: string | number - ID of the item to update
- updater: (item: T) => T - Function that transforms the existing item
- mutationFn: (item: T) => Promise - Function that updates the item on the server
- getId?: (item: T) => string | number - Optional function to extract IDReturns:
UseMutationResult####
useOptimisticDeleteDeletes an item optimistically.
`tsx
const mutation = useOptimisticDelete({
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
strategy: 'flat', // or 'infinite' for infinite queries
getId: (item) => item.id, // Optional
});
`Options:
-
queryKey: QueryKey - The query key to update
- id: string | number - ID of the item to delete
- mutationFn: (id: string | number) => Promise - Function that deletes the item on the server
- strategy?: 'flat' | 'infinite' - Strategy for handling deletions (defaults to 'flat')
- getId?: (item: T) => string | number - Optional function to extract IDReturns:
UseMutationResult####
useOptimisticDeleteWithUndoDeletes an item with undo support. The item is removed immediately but can be restored within a timeout.
`tsx
const mutation = useOptimisticDeleteWithUndo({
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
undoTimeout: 5000, // 5 seconds (default)
getId: (item) => item.id, // Optional
});// To undo, call mutation.reset() before the timeout expires
// Or use restoreDeletedItem() helper with the context
`Options:
-
queryKey: QueryKey - The query key to update
- id: string | number - ID of the item to delete
- mutationFn: (id: string | number) => Promise - Function that deletes the item on the server
- undoTimeout?: number - Timeout in milliseconds before deletion is committed (defaults to 5000)
- getId?: (item: T) => string | number - Optional function to extract IDReturns:
UseMutationResult$3
These functions can be used with
useMutation from TanStack Query. They require QueryClientProvider context, or you can use the *WithClient variants with an explicit QueryClient.####
optimisticCreateCreates a new item optimistically. Handles temporary IDs that get replaced by server IDs.
`tsx
const mutation = useMutation(
optimisticCreate({
queryKey: ['todos'],
newItem: { title: 'New Todo', completed: false },
mutationFn: createTodo,
getId: (item) => item.id, // Optional, defaults to item.id
})
);
`Options:
-
queryKey: QueryKey - The query key to update
- newItem: T - The new item to add optimistically
- mutationFn: (item: T) => Promise - Function that creates the item on the server
- getId?: (item: T) => string | number - Optional function to extract ID (defaults to item.id)Returns:
UseMutationOptions####
optimisticCreateWithClientSame as
optimisticCreate, but accepts an explicit QueryClient. Use this when you have access to QueryClient outside of React components.`tsx
const mutation = useMutation(
optimisticCreateWithClient(queryClient, {
queryKey: ['todos'],
newItem: { title: 'New Todo', completed: false },
mutationFn: createTodo,
})
);
`Parameters:
-
queryClient: QueryClient - The TanStack Query client instance
- options: OptimisticCreateOptions - Same options as optimisticCreateReturns:
UseMutationOptions####
optimisticUpdateUpdates an existing item optimistically.
`tsx
const mutation = useMutation(
optimisticUpdate({
queryKey: ['todos'],
id: todoId,
updater: (todo) => ({ ...todo, completed: !todo.completed }),
mutationFn: updateTodo,
getId: (item) => item.id, // Optional
})
);
`Options:
-
queryKey: QueryKey - The query key to update
- id: string | number - ID of the item to update
- updater: (item: T) => T - Function that transforms the existing item
- mutationFn: (item: T) => Promise - Function that updates the item on the server
- getId?: (item: T) => string | number - Optional function to extract IDReturns:
UseMutationOptions####
optimisticUpdateWithClientSame as
optimisticUpdate, but accepts an explicit QueryClient.`tsx
const mutation = useMutation(
optimisticUpdateWithClient(queryClient, {
queryKey: ['todos'],
id: todoId,
updater: (todo) => ({ ...todo, completed: !todo.completed }),
mutationFn: updateTodo,
})
);
`Parameters:
-
queryClient: QueryClient - The TanStack Query client instance
- options: OptimisticUpdateOptions - Same options as optimisticUpdateReturns:
UseMutationOptions####
optimisticDeleteDeletes an item optimistically.
`tsx
const mutation = useMutation(
optimisticDelete({
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
strategy: 'flat', // or 'infinite' for infinite queries
getId: (item) => item.id, // Optional
})
);
`Options:
-
queryKey: QueryKey - The query key to update
- id: string | number - ID of the item to delete
- mutationFn: (id: string | number) => Promise - Function that deletes the item on the server
- strategy?: 'flat' | 'infinite' - Strategy for handling deletions (defaults to 'flat')
- getId?: (item: T) => string | number - Optional function to extract IDReturns:
UseMutationOptions####
optimisticDeleteWithClientSame as
optimisticDelete, but accepts an explicit QueryClient.`tsx
const mutation = useMutation(
optimisticDeleteWithClient(queryClient, {
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
})
);
`Parameters:
-
queryClient: QueryClient - The TanStack Query client instance
- options: OptimisticDeleteOptions - Same options as optimisticDeleteReturns:
UseMutationOptions####
optimisticDeleteWithUndoDeletes an item with undo support. The item is removed immediately but can be restored within a timeout.
`tsx
const mutation = useMutation(
optimisticDeleteWithUndo({
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
undoTimeout: 5000, // 5 seconds (default)
getId: (item) => item.id, // Optional
})
);// To undo, call mutation.reset() before the timeout expires
// Or use restoreDeletedItem() helper with the context
`Options:
-
queryKey: QueryKey - The query key to update
- id: string | number - ID of the item to delete
- mutationFn: (id: string | number) => Promise - Function that deletes the item on the server
- undoTimeout?: number - Timeout in milliseconds before deletion is committed (defaults to 5000)
- getId?: (item: T) => string | number - Optional function to extract IDReturns:
UseMutationOptions####
optimisticDeleteWithUndoWithClientSame as
optimisticDeleteWithUndo, but accepts an explicit QueryClient.`tsx
const mutation = useMutation(
optimisticDeleteWithUndoWithClient(queryClient, {
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
undoTimeout: 5000,
})
);
`Parameters:
-
queryClient: QueryClient - The TanStack Query client instance
- options: OptimisticDeleteWithUndoOptions - Same options as optimisticDeleteWithUndoReturns:
UseMutationOptions####
restoreDeletedItemHelper function to restore a deleted item (for undo functionality). This should be called when the user clicks "undo".
`tsx
import { restoreDeletedItem } from '@meetdhanani/optimistic-ui';// In your undo handler
const handleUndo = () => {
if (mutation.context?.deletedItem) {
restoreDeletedItem(
queryClient,
['todos'],
mutation.context.deletedItem
);
mutation.reset();
}
};
`Parameters:
-
queryClient: QueryClient - The TanStack Query client instance
- queryKey: QueryKey - The query key to update
- deletedItem: T - The item to restoreReturns:
voidExamples
$3
`tsx
import { useQuery } from '@tanstack/react-query';
import {
useOptimisticCreate,
useOptimisticUpdate,
useOptimisticDelete
} from '@meetdhanani/optimistic-ui';interface Todo {
id: string;
title: string;
completed: boolean;
}
function TodoList() {
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const createMutation = useOptimisticCreate({
queryKey: ['todos'],
newItem: { id: '', title: 'New Todo', completed: false },
mutationFn: createTodo,
});
const updateMutation = useOptimisticUpdate({
queryKey: ['todos'],
id: '', // Will be provided when calling mutate
updater: (todo) => ({ ...todo, completed: !todo.completed }),
mutationFn: updateTodo,
});
const deleteMutation = useOptimisticDelete({
queryKey: ['todos'],
id: '', // Will be provided when calling mutate
mutationFn: deleteTodo,
});
return (
{todos?.map((todo) => (
type="checkbox"
checked={todo.completed}
onChange={() => updateMutation.mutate(todo)}
/>
{todo.title}
))}
);
}
`$3
`tsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { useOptimisticCreate } from '@meetdhanani/optimistic-ui';function InfiniteTodoList() {
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: ['todos'],
queryFn: ({ pageParam }) => fetchTodos({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const createMutation = useOptimisticCreate({
queryKey: ['todos'],
newItem: { id: '', title: 'New Todo', completed: false },
mutationFn: createTodo,
});
// The library automatically handles infinite query structures
// New items are added to the first page
}
`$3
`tsx
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useOptimisticDeleteWithUndo, restoreDeletedItem } from '@meetdhanani/optimistic-ui';function TodoWithUndo() {
const queryClient = useQueryClient();
const [undoId, setUndoId] = useState(null);
const deleteMutation = useOptimisticDeleteWithUndo({
queryKey: ['todos'],
id: '', // Will be provided when calling mutate
mutationFn: deleteTodo,
undoTimeout: 5000,
});
const handleDelete = (id: string) => {
deleteMutation.mutate(id);
setUndoId(id);
setTimeout(() => setUndoId(null), 5000);
};
const handleUndo = () => {
if (deleteMutation.context?.deletedItem) {
restoreDeletedItem(queryClient, ['todos'], deleteMutation.context.deletedItem);
deleteMutation.reset();
setUndoId(null);
}
};
return (
{undoId && (
Item deleted
)}
);
}
`
Migration Guide
$3
`tsx
const mutation = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [
{ ...newTodo, id: temp-${Date.now()} },
...(old || []),
]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSuccess: (data, variables, context) => {
// Replace temp ID with server ID
queryClient.setQueryData(['todos'], (old) =>
old?.map((todo) =>
todo.id === context.tempId ? data : todo
) ?? [data]
);
},
});
`$3
Using hooks (recommended):
`tsx
const mutation = useOptimisticCreate({
queryKey: ['todos'],
newItem: newTodo,
mutationFn: createTodo,
});
`Or using functions:
`tsx
const mutation = useMutation(
optimisticCreate({
queryKey: ['todos'],
newItem: newTodo,
mutationFn: createTodo,
})
);
`Benefits:
- β
90% less code
- β
Automatic temp ID handling
- β
Works with infinite queries out of the box
- β
Type-safe
- β
Handles edge cases automatically
$3
Without the library, you'd need to write ~150 lines of boilerplate for each mutation:
- β Manual temp ID generation
- β Manual array extraction from object pages
- β Manual cache updates in
onMutate
- β Manual rollback in onError
- β Manual temp ID replacement in onSuccess`With the library, just 5 lines:
- β
Automatic temp ID generation
- β
Automatic array extraction
- β
Automatic cache updates
- β
Automatic rollback
- β
Automatic temp ID replacement
- β
Handles all edge cases
- β
Type-safe and tested
- β
Concurrent Mutations - Cancels in-flight queries to prevent overwrites
- β
Temporary IDs - Automatically replaces temp IDs with server IDs
- β
Pagination - Correctly handles infinite query structures
- β
Undo Cancellation - Properly cleans up timeouts and restores state
- β
SSR Safety - Prevents hydration mismatches
- β
Stale Cache - Preserves referential integrity
- React 18+
- TanStack Query v5+
MIT
Contributions are welcome! Please open an issue or submit a pull request.
If you encounter any issues or have questions, please open an issue on GitHub.