@meetdhanani/optimistic-ui
v0.1.6
Published
A tiny, type-safe toolkit that eliminates boilerplate for optimistic UI updates using TanStack Query
Maintainers
Readme
optimistic-ui
A tiny, type-safe toolkit that eliminates boilerplate for optimistic UI updates using TanStack Query.
What is Optimistic UI?
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
The Problem
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:
- Cancel in-flight queries to prevent race conditions
- Snapshot previous data for rollback on errors
- Generate temporary IDs for new items (and replace them later)
- Handle cache updates for different data structures (arrays, infinite queries, etc.)
- Implement error rollback logic
- 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
Why This Library?
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
Features
- ✅ 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
Running Examples
This repository includes working examples to help you get started:
React Example
# Install dependencies (from root)
pnpm install
# Run the React example
pnpm dev:examples
# Or from the example directory:
cd examples/react
pnpm install
pnpm devImportant: 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
Infinite Query Example
cd examples/infinite-query
pnpm install
pnpm devWhat the infinite query example shows:
- ✅ Optimistic updates with paginated data
- ✅ Real API integration (JSONPlaceholder)
- ✅ Handling object-based page structures
- ✅ Error simulation and rollback
Installation
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-queryQuick Start
Recommended: Hook-based API
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
);
}Alternative: Function-based API
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
Available Exports
Hooks (Recommended):
useOptimisticCreate<T>- Create items optimisticallyuseOptimisticUpdate<T>- Update items optimisticallyuseOptimisticDelete<T>- Delete items optimisticallyuseOptimisticDeleteWithUndo<T>- Delete items with undo support
Functions (For use with useMutation or outside React):
optimisticCreate<T>/optimisticCreateWithClient<T>- Create items optimisticallyoptimisticUpdate<T>/optimisticUpdateWithClient<T>- Update items optimisticallyoptimisticDelete<T>/optimisticDeleteWithClient<T>- Delete items optimisticallyoptimisticDeleteWithUndo<T>/optimisticDeleteWithUndoWithClient<T>- Delete items with undorestoreDeletedItem<T>- Helper to restore deleted items (for undo functionality)
Hooks (Recommended)
The hook-based API is recommended as it provides more reliable QueryClient access through React context.
useOptimisticCreate<T>
Creates a new item optimistically. Handles temporary IDs that get replaced by server IDs.
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 updatenewItem: T- The new item to add optimisticallymutationFn: (item: T) => Promise<T>- Function that creates the item on the servergetId?: (item: T) => string | number- Optional function to extract ID (defaults toitem.id)
Returns: UseMutationResult<T, Error, T, OptimisticContext<T>>
useOptimisticUpdate<T>
Updates an existing item optimistically.
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 updateid: string | number- ID of the item to updateupdater: (item: T) => T- Function that transforms the existing itemmutationFn: (item: T) => Promise<T>- Function that updates the item on the servergetId?: (item: T) => string | number- Optional function to extract ID
Returns: UseMutationResult<T, Error, T, OptimisticContext<T>>
useOptimisticDelete<T>
Deletes an item optimistically.
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 updateid: string | number- ID of the item to deletemutationFn: (id: string | number) => Promise<void>- Function that deletes the item on the serverstrategy?: 'flat' | 'infinite'- Strategy for handling deletions (defaults to 'flat')getId?: (item: T) => string | number- Optional function to extract ID
Returns: UseMutationResult<void, Error, string | number, OptimisticContext<T>>
useOptimisticDeleteWithUndo<T>
Deletes an item with undo support. The item is removed immediately but can be restored within a timeout.
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 contextOptions:
queryKey: QueryKey- The query key to updateid: string | number- ID of the item to deletemutationFn: (id: string | number) => Promise<void>- Function that deletes the item on the serverundoTimeout?: number- Timeout in milliseconds before deletion is committed (defaults to 5000)getId?: (item: T) => string | number- Optional function to extract ID
Returns: UseMutationResult<void, Error, string | number, UndoContext<T>>
Functions (For use outside React or with explicit QueryClient)
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.
optimisticCreate<T>
Creates a new item optimistically. Handles temporary IDs that get replaced by server IDs.
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 updatenewItem: T- The new item to add optimisticallymutationFn: (item: T) => Promise<T>- Function that creates the item on the servergetId?: (item: T) => string | number- Optional function to extract ID (defaults toitem.id)
Returns: UseMutationOptions<T, Error, T, OptimisticContext<T>>
optimisticCreateWithClient<T>
Same as optimisticCreate, but accepts an explicit QueryClient. Use this when you have access to QueryClient outside of React components.
const mutation = useMutation(
optimisticCreateWithClient(queryClient, {
queryKey: ['todos'],
newItem: { title: 'New Todo', completed: false },
mutationFn: createTodo,
})
);Parameters:
queryClient: QueryClient- The TanStack Query client instanceoptions: OptimisticCreateOptions<T>- Same options asoptimisticCreate
Returns: UseMutationOptions<T, Error, T, OptimisticContext<T>>
optimisticUpdate<T>
Updates an existing item optimistically.
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 updateid: string | number- ID of the item to updateupdater: (item: T) => T- Function that transforms the existing itemmutationFn: (item: T) => Promise<T>- Function that updates the item on the servergetId?: (item: T) => string | number- Optional function to extract ID
Returns: UseMutationOptions<T, Error, T, OptimisticContext<T>>
optimisticUpdateWithClient<T>
Same as optimisticUpdate, but accepts an explicit QueryClient.
const mutation = useMutation(
optimisticUpdateWithClient(queryClient, {
queryKey: ['todos'],
id: todoId,
updater: (todo) => ({ ...todo, completed: !todo.completed }),
mutationFn: updateTodo,
})
);Parameters:
queryClient: QueryClient- The TanStack Query client instanceoptions: OptimisticUpdateOptions<T>- Same options asoptimisticUpdate
Returns: UseMutationOptions<T, Error, T, OptimisticContext<T>>
optimisticDelete<T>
Deletes an item optimistically.
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 updateid: string | number- ID of the item to deletemutationFn: (id: string | number) => Promise<void>- Function that deletes the item on the serverstrategy?: 'flat' | 'infinite'- Strategy for handling deletions (defaults to 'flat')getId?: (item: T) => string | number- Optional function to extract ID
Returns: UseMutationOptions<void, Error, string | number, OptimisticContext<T>>
optimisticDeleteWithClient<T>
Same as optimisticDelete, but accepts an explicit QueryClient.
const mutation = useMutation(
optimisticDeleteWithClient(queryClient, {
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
})
);Parameters:
queryClient: QueryClient- The TanStack Query client instanceoptions: OptimisticDeleteOptions<T>- Same options asoptimisticDelete
Returns: UseMutationOptions<void, Error, string | number, OptimisticContext<T>>
optimisticDeleteWithUndo<T>
Deletes an item with undo support. The item is removed immediately but can be restored within a timeout.
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 contextOptions:
queryKey: QueryKey- The query key to updateid: string | number- ID of the item to deletemutationFn: (id: string | number) => Promise<void>- Function that deletes the item on the serverundoTimeout?: number- Timeout in milliseconds before deletion is committed (defaults to 5000)getId?: (item: T) => string | number- Optional function to extract ID
Returns: UseMutationOptions<void, Error, string | number, UndoContext<T>>
optimisticDeleteWithUndoWithClient<T>
Same as optimisticDeleteWithUndo, but accepts an explicit QueryClient.
const mutation = useMutation(
optimisticDeleteWithUndoWithClient(queryClient, {
queryKey: ['todos'],
id: todoId,
mutationFn: deleteTodo,
undoTimeout: 5000,
})
);Parameters:
queryClient: QueryClient- The TanStack Query client instanceoptions: OptimisticDeleteWithUndoOptions<T>- Same options asoptimisticDeleteWithUndo
Returns: UseMutationOptions<void, Error, string | number, UndoContext<T>>
restoreDeletedItem<T>
Helper function to restore a deleted item (for undo functionality). This should be called when the user clicks "undo".
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 instancequeryKey: QueryKey- The query key to updatedeletedItem: T- The item to restore
Returns: void
Examples
Basic Todo List
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<Todo[]>({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const createMutation = useOptimisticCreate<Todo>({
queryKey: ['todos'],
newItem: { id: '', title: 'New Todo', completed: false },
mutationFn: createTodo,
});
const updateMutation = useOptimisticUpdate<Todo>({
queryKey: ['todos'],
id: '', // Will be provided when calling mutate
updater: (todo) => ({ ...todo, completed: !todo.completed }),
mutationFn: updateTodo,
});
const deleteMutation = useOptimisticDelete<Todo>({
queryKey: ['todos'],
id: '', // Will be provided when calling mutate
mutationFn: deleteTodo,
});
return (
<div>
{todos?.map((todo) => (
<div key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => updateMutation.mutate(todo)}
/>
<span>{todo.title}</span>
<button onClick={() => deleteMutation.mutate(todo.id)}>Delete</button>
</div>
))}
<button onClick={() => createMutation.mutate({ id: '', title: 'New', completed: false })}>
Add Todo
</button>
</div>
);
}Infinite Queries
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<Todo>({
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
}Delete with Undo
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<string | null>(null);
const deleteMutation = useOptimisticDeleteWithUndo<Todo>({
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 (
<div>
{undoId && (
<div>
Item deleted
<button onClick={handleUndo}>Undo</button>
</div>
)}
</div>
);
}Migration Guide
Before (Manual Optimistic Updates)
const mutation = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['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<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === context.tempId ? data : todo
) ?? [data]
);
},
});After (With optimistic-ui)
Using hooks (recommended):
const mutation = useOptimisticCreate({
queryKey: ['todos'],
newItem: newTodo,
mutationFn: createTodo,
});Or using functions:
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
Manual vs Library Comparison
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 - ❌ Manual structure preservation (array vs object pages)
- ❌ Error-prone and repetitive
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
Edge Cases Handled
- ✅ 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
Requirements
- React 18+
- TanStack Query v5+
License
MIT
Contributing
Contributions are welcome! Please open an issue or submit a pull request.
Support
If you encounter any issues or have questions, please open an issue on GitHub.
