A custom React hook that enables optimistic UI updates by immediately reflecting user actions while async operations complete in the background, with automatic rollback on failure.
import { useState, useCallback, useRef } from 'react';
// Types for the optimistic hook
interface UseOptimisticOptions<T> {
onError?: (error: Error, rollbackValue: T) => void;
}
interface UseOptimisticReturn<T> {
value: T;
isPending: boolean;
error: Error | null;
update: (newValue: T, asyncOperation: () => Promise<void>) => Promise<void>;
reset: () => void;
}
// The useOptimistic hook
function useOptimistic<T>(
initialValue: T,
options: UseOptimisticOptions<T> = {}
): UseOptimisticReturn<T> {
const [value, setValue] = useState<T>(initialValue);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<Error | null>(null);
const rollbackValueRef = useRef<T>(initialValue);
const pendingOperationRef = useRef<symbol | null>(null);
const update = useCallback(
async (newValue: T, asyncOperation: () => Promise<void>): Promise<void> => {
const operationId = Symbol('operation');
pendingOperationRef.current = operationId;
// Store current value for potential rollback
rollbackValueRef.current = value;
// Optimistically update immediately
setValue(newValue);
setIsPending(true);
setError(null);
try {
await asyncOperation();
// Only clear pending if this is still the current operation
if (pendingOperationRef.current === operationId) {
setIsPending(false);
}
} catch (err) {
// Only rollback if this is still the current operation
if (pendingOperationRef.current === operationId) {
const errorInstance = err instanceof Error ? err : new Error(String(err));
// Rollback to previous value
setValue(rollbackValueRef.current);
setError(errorInstance);
setIsPending(false);
// Call error handler if provided
options.onError?.(errorInstance, rollbackValueRef.current);
}
}
},
[value, options]
);
const reset = useCallback(() => {
pendingOperationRef.current = null;
setError(null);
setIsPending(false);
}, []);
return { value, isPending, error, update, reset };
}
// Like button types
interface LikeState {
isLiked: boolean;
count: number;
}
interface LikeButtonProps {
postId: string;
initialLiked: boolean;
initialCount: number;
}
// Simulated API function
const likePostApi = async (postId: string, shouldLike: boolean): Promise<void> => {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 1000));
// Simulate random failure for demonstration (20% chance)
if (Math.random() < 0.2) {
throw new Error('Network error: Failed to update like status');
}
console.log(`API: Post ${postId} ${shouldLike ? 'liked' : 'unliked'} successfully`);
};
// Like Button Component
function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps): JSX.Element {
const { value: likeState, isPending, error, update } = useOptimistic<LikeState>(
{ isLiked: initialLiked, count: initialCount },
{
onError: (err) => {
console.error('Like operation failed:', err.message);
}
}
);
const handleLikeClick = async (): Promise<void> => {
const newLikedState = !likeState.isLiked;
const newCount = newLikedState ? likeState.count + 1 : likeState.count - 1;
await update(
{ isLiked: newLikedState, count: newCount },
() => likePostApi(postId, newLikedState)
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<button
onClick={handleLikeClick}
disabled={isPending}
style={{
padding: '12px 24px',
fontSize: '16px',
cursor: isPending ? 'wait' : 'pointer',
backgroundColor: likeState.isLiked ? '#e91e63' : '#f5f5f5',
color: likeState.isLiked ? 'white' : '#333',
border: 'none',
borderRadius: '8px',
transition: 'all 0.2s ease',
opacity: isPending ? 0.7 : 1,
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
<span>{likeState.isLiked ? '❤️' : '🤍'}</span>
<span>{likeState.count} {likeState.count === 1 ? 'Like' : 'Likes'}</span>
{isPending && <span style={{ fontSize: '12px' }}>⏳</span>}
</button>
{error && (
<div style={{
color: '#d32f2f',
fontSize: '14px',
padding: '8px 12px',
backgroundColor: '#ffebee',
borderRadius: '4px'
}}>
{error.message} - Rolled back!
</div>
)}
</div>
);
}
// Demo App Component
function App(): JSX.Element {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
fontFamily: 'system-ui, sans-serif',
gap: '24px'
}}>
<h1>Optimistic Like Button Demo</h1>
<p style={{ color: '#666', maxWidth: '400px', textAlign: 'center' }}>
Click the button to like. The UI updates instantly.
There's a 20% chance of failure to demonstrate rollback.
</p>
<div style={{ display: 'flex', gap: '32px' }}>
<div>
<h3>Post 1</h3>
<LikeButton postId="post-1" initialLiked={false} initialCount={42} />
</div>
<div>
<h3>Post 2</h3>
<LikeButton postId="post-2" initialLiked={true} initialCount={128} />
</div>
</div>
</div>
);
}
export { useOptimistic, LikeButton, App };
export type { UseOptimisticOptions, UseOptimisticReturn, LikeState, LikeButtonProps };The useOptimistic hook implements a pattern that makes your UI feel instantaneous by updating state immediately when a user takes an action, rather than waiting for the server response. This is crucial for perceived performance—users see their actions reflected right away, making the app feel responsive even on slow networks.
The hook maintains three pieces of state: the current value, a pending flag, and any error that occurred. It also uses refs to track the previous value for rollback purposes and a unique symbol to identify each operation. The symbol-based operation tracking is essential because it prevents race conditions when users click rapidly—only the most recent operation's result affects the UI, and stale responses are ignored.
The update function is the core of the pattern. It first captures the current value as a rollback point, then immediately sets the optimistic new value. This creates the instant feedback users expect. The async operation runs in the background, and if it fails, the hook automatically reverts to the stored rollback value. The error callback option allows parent components to handle failures with custom logic like showing toast notifications.
The LikeButton component demonstrates a real-world use case. When clicked, it calculates the new like state and count, then passes both the optimistic value and the API call to the update function. Notice how the button shows a loading indicator but remains interactive conceptually—the visual state has already changed. The 20% simulated failure rate lets you observe the rollback behavior in action.
Use this pattern when you need responsive UIs for actions that almost always succeed, like likes, follows, or toggles. Avoid it for critical operations where showing pending state is important (like payments) or when the operation has complex server-side validation that frequently fails. Also consider that this pattern can lead to temporary inconsistencies if multiple users are modifying the same data simultaneously—for collaborative features, you might need more sophisticated conflict resolution.