Module P-3·11 min read

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.

Written by Jatin Jain Saraf · Architect of SupraScan

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 — useOptimistic for immediate UI updates, useActionState for 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:

  1. Pending state — show that something is happening (spinner, disabled state)
  2. Optimistic update — show the expected result immediately
  3. 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 — true while 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 & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.