How to make Server Actions feel instant — useOptimistic for immediate UI updates, useActionState for form state management, pending state patterns, and rolling back on error. The bridge between server-actions-mutations and at-scale.
Optimistic UI: useOptimistic, useActionState, and the Pending State Pattern
Who this module is for: You have Server Actions working — the form submits, the data saves, the page updates. But the UI feels sluggish: users click a button and stare at a spinner while the server round-trip completes. This module covers the three React 19 hooks that make Server Actions feel instant —
useOptimisticfor immediate UI updates,useActionStatefor form state management, and the pending state pattern for visual feedback.
Why Optimistic UI Matters
A Server Action invokes a round-trip to your server: the browser sends a request, Next.js executes the action, revalidates the cache, and the updated UI flows back down. Even on a fast connection, this takes 200–800ms. On a mobile network, 1–3 seconds.
Users have been conditioned by native apps and real-time UIs to expect instant feedback. A button that does nothing for 500ms feels broken. Optimistic UI solves this by updating the UI immediately — before the server confirms — and rolling back if the server reports an error.
The pattern has three layers:
- Pending state — show that something is happening (spinner, disabled state)
- Optimistic update — show the expected result immediately
- Rollback — revert if the server returns an error
React 19 provides a hook for each layer.
useActionState: Form State Management
useActionState (formerly useFormState in React 18) connects a Server Action to a component's state. It gives you the action's return value and a wrapped action function that you pass to a form.
'use client';
import { useActionState } from 'react';
import { createComment } from './actions';
type State = {
error?: string;
success?: boolean;
comment?: { id: string; text: string };
};
export function CommentForm({ postId }: { postId: string }) {
const [state, action, isPending] = useActionState<State, FormData>(
createComment,
{} // initial state
);
return (
<form action={action}>
<input type="hidden" name="postId" value={postId} />
<textarea
name="text"
required
disabled={isPending}
placeholder="Write a comment..."
className="w-full border rounded p-2"
/>
{state.error && (
<p className="text-red-500 text-sm mt-1">{state.error}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Posting...' : 'Post comment'}
</button>
</form>
);
}
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createComment(prevState: State, formData: FormData): Promise<State> {
const postId = formData.get('postId') as string;
const text = formData.get('text') as string;
if (!text || text.length < 3) {
return { error: 'Comment must be at least 3 characters' };
}
try {
const comment = await db.comments.create({ postId, text });
revalidatePath(`/posts/${postId}`);
return { success: true, comment };
} catch {
return { error: 'Failed to post comment. Please try again.' };
}
}
What useActionState gives you:
state— the most recent return value from the Server Action (or initial state on first render)action— a wrapped version of your Server Action to pass to<form action={action}>isPending—truewhile the action is in flight
The signature change: your Server Action must accept prevState as the first argument before formData. The prevState is the previous return value — useful for accumulating state across multiple submissions.
useOptimistic: Instant UI Updates
useOptimistic lets you show a temporary "optimistic" value while an async operation is pending, then automatically revert to the actual value when the operation completes.
'use client';
import { useOptimistic, useActionState } from 'react';
import { toggleLike } from './actions';
type LikeButtonProps = {
postId: string;
initialLiked: boolean;
initialCount: number;
};
export function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps) {
// The real state (synced with server)
const [liked, setLiked] = React.useState(initialLiked);
const [count, setCount] = React.useState(initialCount);
// Optimistic state (shown immediately on click)
const [optimisticLiked, addOptimisticLike] = useOptimistic(
liked,
(currentLiked, action: 'toggle') => !currentLiked // optimistic update function
);
const [optimisticCount, addOptimisticCount] = useOptimistic(
count,
(currentCount, delta: number) => currentCount + delta
);
async function handleLike() {
// 1. Update UI immediately (optimistic)
addOptimisticLike('toggle');
addOptimisticCount(optimisticLiked ? -1 : 1);
// 2. Send to server
const result = await toggleLike(postId);
// 3. If server succeeded, sync real state
if (result.success) {
setLiked(result.liked);
setCount(result.count);
}
// If server failed: useOptimistic automatically reverts to the real state
// because the optimistic value only lives until the async operation resolves
}
return (
<button onClick={handleLike} className="flex items-center gap-2">
<span>{optimisticLiked ? '❤️' : '🤍'}</span>
<span>{optimisticCount}</span>
</button>
);
}
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion