C
Next.js/Rendering/Lesson 08

Optimistic UI — useActionState + useFormStatus + useOptimistic

35 min·theory
This chapter
5/5
TypeScript

Optimistic UI — useActionState + useFormStatus + useOptimistic

💡 Why learn this? — The 'brief pause after clicking' disappears

🎯 When a user clicks the Like button and has to wait 200–500ms for a server response before the UI updates, they start wondering, 'Did that even register?'
💼 Optimistic UI: show the UI as if the action succeeded immediately on click, then reflect the truth once the server responds. Automatically roll back on failure.
The `useOptimistic` hook in React 19 standardizes this pattern — delivering the instant-response UX you see with likes on Twitter and Instagram.
🔗 `useActionState` (formerly useFormState) + `useFormStatus` + `useOptimistic` — these three hooks together complete the form UX for Server Actions.
📈 From the user's perspective: instant feedback on click → the app genuinely feels fast.
🏢 실무에서는
Likes, bookmarks, follows, add-to-cart — actions users click dozens of times every day. A 0.3-second pause on each click adds up to a 'slow site' impression. With `useOptimistic`, the count increments by +1 immediately on click; once the real result comes back from the server, it is reconciled. On failure, -1 is automatically rolled back with an error message.

3 hooks — division of responsibility

1. The role of each hook

HookLocationRole
useActionStateInside the formReceives the Server Action return value as state. Tracks pending
useFormStatusForm childReads the pending state of the nearest parent form
useOptimisticForm sibling/childImmediately updates UI before server response, auto-rollback on failure

2. useActionState (React 19 — replaces the old useFormState)

tsx
import { useActionState } from 'react';

const [state, formAction, isPending] = useActionState(
  serverAction,           // Server Action to execute
  initialState,           // initial state value
);

<form action={formAction}>...</form>

The Action signature changes to (prevState, formData) => newState.

3. useFormStatus — child reads parent form state

tsx
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}

Placed anywhere inside the parent <form>, it automatically receives that form's state. No props drilling.

4. useOptimistic — immediate UI update + auto-rollback

tsx
import { useOptimistic } from 'react';

function LikeButton({ likes }: { likes: number }) {
  // optimistic state — keeps an 'expected value' separate from actual state
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    (current, increment: number) => current + increment,  // reducer
  );

  async function handleClick() {
    addOptimisticLike(1);  // UI immediately +1
    await likeAction();    // server call
    // When done, React automatically replaces with the real likes value
    // On failure, the optimistic value is discarded and the original value is restored
  }

  return <button onClick={handleClick}>❤️ {optimisticLikes}</button>;
}

5. All three together — a complete form

tsx
'use client';
import { useActionState, useOptimistic } from 'react';
import { useFormStatus } from 'react-dom';
import { addCommentAction } from './actions';

export function CommentForm({ comments }: { comments: Comment[] }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state, newText: string) => [...state, { id: Date.now(), text: newText, pending: true }],
  );

  const [state, formAction] = useActionState(
    async (prev: { error: string | null }, formData: FormData) => {
      const text = formData.get('text') as string;
      addOptimisticComment(text);                  // immediately add to UI
      return await addCommentAction(prev, formData); // server processing
    },
    { error: null },
  );

  return (
    <div>
      <ul>
        {optimisticComments.map(c => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            {c.text} {c.pending && '(Sending...)'}
          </li>
        ))}
      </ul>
      <form action={formAction}>
        <input name="text" required />
        <SubmitButton />
        {state.error && <p>{state.error}</p>}
      </form>
    </div>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? '...' : 'Save'}</button>;
}
💻 🅰️ Old approach — wait for response after clicking, wondering 'did it register?'
// ❌ Without Optimistic — UI changes after waiting for server response
'use client';
import { useState, useTransition } from 'react';
import { likeAction } from './actions';

export function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  const [isPending, startTransition] = useTransition();

  async function handleClick() {
    startTransition(async () => {
      // User: click → 200~500ms pause → count change
      const result = await likeAction();
      setLikes(result.likes);
    });
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      ❤️ {likes} {isPending && '(Processing...)'}
    </button>
  );
}

// Disadvantages:
// - Click → 0.3s pause → count change → User 'Did it register?'
// - Even with isPending display, awkwardness remains
💻 🅱️ useOptimistic — +1 immediately on click, server runs in the background
// ✅ useOptimistic — immediate reaction + automatic synchronization
'use client';
import { useOptimistic, useTransition } from 'react';
import { likeAction } from './actions';

export function LikeButton({ initialLikes, postId }: { initialLikes: number; postId: number }) {
  // Define optimistic state — separate from actual likes
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current, increment: number) => current + increment,
  );
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    startTransition(async () => {
      addOptimisticLike(1);              // UI immediately +1
      try {
        await likeAction(postId);
        // Success: React automatically replaces with the real value
      } catch (e) {
        // Failure: React automatically discards optimistic value, restores original value
        console.error('Like failed:', e);
      }
    });
  }

  return (
    <button onClick={handleClick}>
      ❤️ {optimisticLikes}
    </button>
  );
}

// 📁 app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';

export async function likeAction(postId: number) {
  await db.like.create({ data: { postId } });
  revalidatePath(`/posts/${postId}`);
  // After revalidate, server returns new likes value → synchronizes with optimistic value
}

// User experience:
// Click → immediate ❤️ 543 → 545 (0ms)
// Server response: 545 confirmed (background, user unaware)
// If failed: ❤️ automatically reverts to 543 + error toast

// Comment list also follows the same pattern — immediately add new comment, display 'Sending...'
export function CommentList({ initial }: { initial: Comment[] }) {
  const [optimistic, addOptimisticComment] = useOptimistic(
    initial,
    (state, newText: string) => [
      ...state,
      { id: Date.now(), text: newText, pending: true },
    ],
  );

  // ... call addOptimisticComment(formData.get('text')) inside formAction
  return (
    <ul>
      {optimistic.map(c => (
        <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
          {c.text} {c.pending && '(Sending...)'}
        </li>
      ))}
    </ul>
  );
}

💡 💡 Optimistic UI in practice — 5 tips

1. Call useOptimistic inside startTransition

tsx
startTransition(async () => {
  addOptimisticLike(1);
  await action();
});

Calling it without a Transition makes it a synchronous update, which weakens the effect.

2. Add a marker like pending: true to optimistic items
Give the user a visual signal that 'the server hasn't confirmed this yet' (opacity 0.5, 'Sending' label).

3. No extra code needed for failure — React auto-rolls back
If the Server Action throws, the optimistic state is discarded and the original value is restored. Just add an error toast in the catch.

4. Auto-sync when the real value arrives after revalidatePath
Call revalidatePath at the end of the Server Action → page data is re-fetched → the initial value of useOptimistic is updated → optimistic state is automatically discarded.

5. Use with useActionState — manage the entire form state

tsx
const [state, formAction] = useActionState(action, initial);
const [optimistic, addOpt] = useOptimistic(state.items, reducer);
// form errors → state, immediate UI → optimistic

⚡ Try it yourself — Optimistic UI scenarios

Simulate how optimistic state behaves in success and failure cases.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check your understanding

If the server response fails after calling a Server Action, what happens to the value held by useOptimistic?
💡 `useOptimistic` is used inside a Transition; when an action throws or rejects, React automatically discards the optimistic state and restores the original value. In other words, the user briefly sees +1 and then watches it revert automatically. Just show an error toast in the catch block — no manual rollback code needed.
Optimistic UI — useActionState + useOptimistic - Next.js