C
Next.js/렌더링/Lesson 08

Optimistic UI — useActionState + useFormStatus + useOptimistic

35분·theory
이 챕터
5/5
TypeScript

Optimistic UI — useActionState + useFormStatus + useOptimistic

💡 왜 배워야 할까요? — '클릭 후 잠깐 멈춤' 이 사라진다

🎯 좋아요 버튼 누르면 서버 응답 200~500ms 기다린 후 UI 가 바뀌면 사용자는 '눌렸나?' 의심합니다.
💼 Optimistic UI: 클릭 즉시 UI 를 '성공한 것처럼' 보여주고, 서버 응답 오면 진실 반영. 실패하면 자동 롤백.
React 19 의 `useOptimistic` hook 으로 이 패턴이 표준화됨 — Twitter·Instagram 의 좋아요 같은 즉시 반응 UX.
🔗 `useActionState` (구 useFormState) + `useFormStatus` + `useOptimistic` 세 hook 이 Server Action 의 폼 UX 를 완성.
📈 사용자 체감: 클릭 즉시 반응 → 진짜 빠른 앱 느낌.
🏢 실무에서는
좋아요·북마크·팔로우·장바구니 추가 — 사용자가 매일 수십 번 클릭하는 액션들. 매번 0.3초 멈춤이 누적되면 '느린 사이트' 인상. useOptimistic 으로 클릭 즉시 카운트 +1 표시, 서버에서 진짜 결과 받으면 일치 확인. 실패면 -1 자동 롤백 + 에러 메시지.

3가지 hook — 역할 분담

1. 세 가지 hook 의 역할

Hook위치역할
useActionState폼 안Server Action 의 반환값을 state 로 받음. pending 추적
useFormStatus폼 자식가장 가까운 부모 form 의 pending 상태 읽음
useOptimistic폼 형제·자식서버 응답 전 UI 즉시 업데이트, 실패 시 자동 롤백

2. useActionState (React 19 — 구 useFormState 대체)

tsx
import { useActionState } from 'react';

const [state, formAction, isPending] = useActionState(
  serverAction,           // 실행할 Server Action
  initialState,           // state 초기값
);

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

Action 의 시그니처가 (prevState, formData) => newState 로 바뀜.

3. useFormStatus — 자식이 부모 form 상태 읽기

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

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

부모 <form> 안 어디든 위치하면 자동으로 그 form 의 상태를 받음. props drilling X.

4. useOptimistic — 즉시 UI 업데이트 + 자동 롤백

tsx
import { useOptimistic } from 'react';

function LikeButton({ likes }: { likes: number }) {
  // optimistic state — 실제 상태와 별도로 '예상치' 보관
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    (current, increment: number) => current + increment,  // reducer
  );

  async function handleClick() {
    addOptimisticLike(1);  // UI 즉시 +1
    await likeAction();    // 서버 호출
    // 끝나면 React 가 자동으로 진짜 likes 값으로 교체
    // 실패하면 optimistic 값 폐기, 원래 값 복원
  }

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

5. 셋 다 같이 — 완성된 폼

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);                  // UI 즉시 추가
      return await addCommentAction(prev, formData); // 서버 처리
    },
    { error: null },
  );

  return (
    <div>
      <ul>
        {optimisticComments.map(c => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            {c.text} {c.pending && '(전송 중...)'}
          </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 ? '...' : '저장'}</button>;
}
💻 🅰️ 옛 방식 — 클릭 후 응답 대기, '눌렸나?' 의심
// ❌ Optimistic 없이 — 서버 응답 기다린 후 UI 변경
'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 () => {
      // 사용자: 클릭 → 200~500ms 멈춤 → 카운트 변경
      const result = await likeAction();
      setLikes(result.likes);
    });
  }

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

// 단점:
// - 클릭 → 0.3초 멈춤 → 카운트 변경 → 사용자 '눌렸나?'
// - isPending 표시 추가해도 어색함은 남음
💻 🅱️ useOptimistic — 클릭 즉시 +1, 서버는 백그라운드
// ✅ useOptimistic — 즉시 반응 + 자동 동기화
'use client';
import { useOptimistic, useTransition } from 'react';
import { likeAction } from './actions';

export function LikeButton({ initialLikes, postId }: { initialLikes: number; postId: number }) {
  // optimistic state 정의 — 실제 likes 와 별도
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current, increment: number) => current + increment,
  );
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    startTransition(async () => {
      addOptimisticLike(1);              // UI 즉시 +1
      try {
        await likeAction(postId);
        // 성공: React 가 자동으로 진짜 값으로 교체
      } catch (e) {
        // 실패: React 가 자동으로 optimistic 값 폐기, 원래 값 복원
        console.error('좋아요 실패:', 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}`);
  // revalidate 후 서버가 새 likes 값을 반환 → optimistic 값과 동기화
}

// 사용자 체감:
// 클릭 → 즉시 ❤️ 543 → 545 (0ms)
// 서버 응답: 545 확정 (백그라운드, 사용자 모름)
// 만약 실패: ❤️ 543 으로 자동 복원 + 에러 토스트

// 댓글 목록도 같은 패턴 — 즉시 새 댓글 추가, '전송 중' 표시
export function CommentList({ initial }: { initial: Comment[] }) {
  const [optimistic, addOptimisticComment] = useOptimistic(
    initial,
    (state, newText: string) => [
      ...state,
      { id: Date.now(), text: newText, pending: true },
    ],
  );

  // ... formAction 안에서 addOptimisticComment(formData.get('text')) 호출
  return (
    <ul>
      {optimistic.map(c => (
        <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
          {c.text} {c.pending && '(전송 중...)'}
        </li>
      ))}
    </ul>
  );
}

💡 💡 Optimistic UI 실전 5

1. useOptimistic 은 startTransition 안에서 호출

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

Transition 없이 호출하면 동기 업데이트라 효과 약함.

2. optimistic 항목에 pending: true 같은 표식 두기
사용자에게 '아직 서버 확인 안 됐다' 시각 신호 (opacity 0.5, '전송 중' 라벨).

3. 실패 시 별도 코드 불필요 — React 가 자동 롤백
Server Action 이 throw 하면 optimistic state 폐기, 원래 값 복원. catch 에서 에러 토스트만 추가하면 됨.

4. revalidatePath 후 진짜 값이 도착하면 자동 동기화
Server Action 끝에 revalidatePath 호출 → 페이지 데이터 재조회 → useOptimistic 의 initial 값 갱신 → optimistic 자동 폐기.

5. useActionState 와 함께 — 폼 전체 상태 관리

tsx
const [state, formAction] = useActionState(action, initial);
const [optimistic, addOpt] = useOptimistic(state.items, reducer);
// 폼 에러 → state, 즉시 UI → optimistic

⚡ 직접 실행해보기 — Optimistic UI 시나리오

성공·실패 케이스에서 optimistic state 가 어떻게 동작하는지 시뮬레이션.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

Server Action 호출 후 서버 응답이 실패하면 useOptimistic 의 값은?
💡 useOptimistic 은 Transition 안에서 사용되며, action 이 throw 하거나 reject 되면 React 가 자동으로 optimistic state 를 폐기하고 원래 값으로 복원합니다. 즉 사용자는 잠깐 +1 을 봤다가 자동으로 원래 값으로 돌아가는 걸 봅니다. 추가로 catch 에서 에러 토스트만 띄우면 끝 — 수동 롤백 코드 불필요.
Optimistic UI — useActionState + useOptimistic - Next.js