본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 5장: 새로운 훅 - useActionState, useFormStatus, useOptimistic
2026년 1월 31일·웹 개발·

5장: 새로운 훅 - useActionState, useFormStatus, useOptimistic

React 19의 새로운 훅 3종을 심층 분석합니다. 폼 상태 관리, 제출 상태 추적, 낙관적 UI 업데이트의 실전 패턴을 다룹니다.

16분1,026자6개 섹션
reactnextjsperformancefrontendtypescript
공유
react19-rsc5 / 11
1234567891011
이전4장: use() API와 새로운 데이터 패턴다음6장: Suspense 고급 패턴과 스트리밍 SSR

3장에서 Server Actions의 기본 개념을, 4장에서 use() API를 다루었습니다. 이번 장에서는 Server Actions와 함께 사용되도록 설계된 세 가지 새로운 훅을 심층적으로 살펴봅니다. useActionState로 액션 결과를 관리하고, useFormStatus로 폼 제출 상태를 추적하며, useOptimistic으로 즉각적인 UI 피드백을 제공하는 방법을 배웁니다.

useActionState

useActionState는 폼 액션의 상태를 관리하는 훅입니다. useReducer와 유사하지만, 비동기 액션을 지원하고 폼의 action 속성과 직접 통합됩니다.

기본 시그니처

typescript
const [state, formAction, isPending] = useActionState(
  action,      // (prevState, payload) => Promise<newState>
  initialState,  // 초기 상태
  permalink?,    // Progressive Enhancement용 URL (선택)
);
반환값설명
state액션의 현재 결과 상태
formAction<form action>에 전달할 래핑된 액션 함수
isPending액션이 실행 중인지 여부

기본 사용법

actions/newsletter.ts
typescript
'use server';
 
type SubscribeState = {
  message: string;
  success: boolean;
};
 
export async function subscribe(
  prevState: SubscribeState,
  formData: FormData
): Promise<SubscribeState> {
  const email = formData.get('email') as string;
 
  if (!email || !email.includes('@')) {
    return { message: '유효한 이메일을 입력해주세요.', success: false };
  }
 
  try {
    await addSubscriber(email);
    return { message: '구독이 완료되었습니다!', success: true };
  } catch {
    return { message: '구독 처리 중 오류가 발생했습니다.', success: false };
  }
}
components/NewsletterForm.tsx
typescript
'use client';
 
import { useActionState } from 'react';
import { subscribe } from '@/actions/newsletter';
 
function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(subscribe, {
    message: '',
    success: false,
  });
 
  return (
    <form action={formAction}>
      <div>
        <input
          name="email"
          type="email"
          placeholder="이메일 주소"
          disabled={isPending}
          required
        />
        <button type="submit" disabled={isPending}>
          {isPending ? '처리 중...' : '구독'}
        </button>
      </div>
 
      {state.message && (
        <p className={state.success ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </p>
      )}
    </form>
  );
}

prevState 활용

useActionState의 액션 함수는 첫 번째 인자로 이전 상태를 받습니다. 이를 활용하면 누적 상태를 관리할 수 있습니다.

시도 횟수 추적
typescript
'use server';
 
type LoginState = {
  error: string | null;
  attempts: number;
};
 
export async function login(
  prevState: LoginState,
  formData: FormData
): Promise<LoginState> {
  const attempts = prevState.attempts + 1;
 
  if (attempts > 5) {
    return {
      error: '로그인 시도 횟수를 초과했습니다. 잠시 후 다시 시도해주세요.',
      attempts,
    };
  }
 
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
 
  const result = await authenticate(email, password);
 
  if (!result.success) {
    return {
      error: `이메일 또는 비밀번호가 올바르지 않습니다. (${attempts}/5)`,
      attempts,
    };
  }
 
  redirect('/dashboard');
}

폼이 아닌 곳에서 사용

useActionState는 폼이 아닌 곳에서도 사용할 수 있습니다. formAction을 직접 호출하면 됩니다.

버튼 클릭으로 액션 실행
typescript
'use client';
 
import { useActionState } from 'react';
import { incrementCounter } from '@/actions/counter';
 
function Counter() {
  const [state, action, isPending] = useActionState(incrementCounter, {
    count: 0,
  });
 
  return (
    <div>
      <p>카운트: {state.count}</p>
      <button onClick={() => action()} disabled={isPending}>
        {isPending ? '처리 중...' : '+1'}
      </button>
    </div>
  );
}

순차 실행 보장

useActionState의 중요한 특성은 여러 번 호출해도 순차적으로 실행된다는 것입니다. 사용자가 버튼을 빠르게 여러 번 클릭해도 각 호출이 이전 호출의 완료를 기다립니다.

순차 실행
typescript
// 사용자가 버튼을 3번 빠르게 클릭하면:
// 1번째 호출: prevState = { count: 0 } → { count: 1 }
// 2번째 호출: prevState = { count: 1 } → { count: 2 } (1번째 완료 후 실행)
// 3번째 호출: prevState = { count: 2 } → { count: 3 } (2번째 완료 후 실행)

permalink을 통한 Progressive Enhancement

세 번째 인자 permalink은 JavaScript가 로드되기 전에 폼이 제출될 경우 브라우저가 이동할 URL을 지정합니다.

permalink 활용
typescript
const [state, formAction, isPending] = useActionState(
  subscribe,
  { message: '', success: false },
  '/newsletter/subscribed'  // JS 미로드 시 이 URL로 이동
);

useFormStatus

useFormStatus는 부모 <form>의 제출 상태를 읽는 훅입니다. react-dom에서 가져옵니다.

기본 시그니처

typescript
import { useFormStatus } from 'react-dom';
 
const { pending, data, method, action } = useFormStatus();
속성타입설명
pendingboolean폼이 제출 중인지 여부
dataFormData | null제출된 폼 데이터
methodstringHTTP 메서드 (get/post)
actionfunction | stringform의 action 속성값

핵심 규칙: 자식 컴포넌트에서 호출

useFormStatus는 반드시 <form> 내부의 자식 컴포넌트에서 호출해야 합니다. <form>을 렌더링하는 같은 컴포넌트에서는 동작하지 않습니다.

올바른 사용법
typescript
// 자식 컴포넌트로 분리
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? '제출 중...' : '제출'}
    </button>
  );
}
 
// 폼에서 사용
function ContactForm() {
  return (
    <form action={submitAction}>
      <input name="message" />
      <SubmitButton />  {/* 자식 컴포넌트 */}
    </form>
  );
}
동작하지 않는 패턴
typescript
function ContactForm() {
  // 같은 컴포넌트에서 호출하면 항상 pending: false
  const { pending } = useFormStatus();
 
  return (
    <form action={submitAction}>
      <input name="message" />
      <button disabled={pending}>제출</button>
    </form>
  );
}

재사용 가능한 폼 컴포넌트

useFormStatus를 활용하면 범용적인 폼 UI 컴포넌트를 만들 수 있습니다.

범용 제출 버튼
typescript
'use client';
 
import { useFormStatus } from 'react-dom';
 
interface SubmitButtonProps {
  children: React.ReactNode;
  pendingText?: string;
  className?: string;
}
 
function SubmitButton({
  children,
  pendingText = '처리 중...',
  className,
}: SubmitButtonProps) {
  const { pending } = useFormStatus();
 
  return (
    <button
      type="submit"
      disabled={pending}
      className={className}
      aria-disabled={pending}
    >
      {pending ? pendingText : children}
    </button>
  );
}
폼 필드 비활성화 래퍼
typescript
'use client';
 
import { useFormStatus } from 'react-dom';
 
function FormFields({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();
 
  return (
    <fieldset disabled={pending} className={pending ? 'opacity-60' : ''}>
      {children}
    </fieldset>
  );
}
조합하여 사용
typescript
function ArticleForm() {
  return (
    <form action={createArticle}>
      <FormFields>
        <input name="title" placeholder="제목" />
        <textarea name="content" placeholder="내용" />
      </FormFields>
      <SubmitButton pendingText="저장 중...">저장</SubmitButton>
    </form>
  );
}

data 속성 활용

data 속성으로 제출 중인 폼 데이터에 접근할 수 있습니다.

제출 중인 데이터 표시
typescript
'use client';
 
import { useFormStatus } from 'react-dom';
 
function PendingPreview() {
  const { pending, data } = useFormStatus();
 
  if (!pending || !data) return null;
 
  return (
    <div className="bg-gray-100 p-4 rounded">
      <p className="text-sm text-gray-500">저장 중인 내용:</p>
      <p className="font-medium">{data.get('title') as string}</p>
    </div>
  );
}

useOptimistic

useOptimistic은 비동기 작업이 완료되기 전에 UI를 미리 업데이트하는 낙관적 업데이트(Optimistic Update) 패턴을 구현합니다.

기본 시그니처

typescript
const [optimisticValue, setOptimistic] = useOptimistic(
  actualValue,  // 실제 값 (서버 응답 후 적용될 값)
  updateFn?,    // (currentOptimistic, newValue) => updatedOptimistic
);

작동 원리

단순한 토글 패턴

좋아요 토글
typescript
'use client';
 
import { useOptimistic, useTransition } from 'react';
import { toggleBookmark } from '@/actions/bookmark';
 
function BookmarkButton({ articleId, isBookmarked }: {
  articleId: string;
  isBookmarked: boolean;
}) {
  const [optimisticBookmarked, setOptimisticBookmarked] =
    useOptimistic(isBookmarked);
  const [, startTransition] = useTransition();
 
  function handleClick() {
    startTransition(async () => {
      setOptimisticBookmarked(!optimisticBookmarked);
      await toggleBookmark(articleId);
    });
  }
 
  return (
    <button onClick={handleClick} aria-pressed={optimisticBookmarked}>
      {optimisticBookmarked ? '북마크 해제' : '북마크 추가'}
    </button>
  );
}

리스트 업데이트 패턴

reducer 함수를 사용하면 리스트에 아이템을 추가/삭제하는 복잡한 낙관적 업데이트도 구현할 수 있습니다.

댓글 낙관적 추가
typescript
'use client';
 
import { useOptimistic, useActionState } from 'react';
import { addComment } from '@/actions/comment';
 
interface Comment {
  id: string;
  text: string;
  author: string;
  pending?: boolean;
}
 
function CommentSection({
  articleId,
  initialComments,
  currentUser,
}: {
  articleId: string;
  initialComments: Comment[];
  currentUser: string;
}) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    initialComments,
    (currentComments: Comment[], newComment: { text: string }) => [
      ...currentComments,
      {
        id: `temp-${Date.now()}`,
        text: newComment.text,
        author: currentUser,
        pending: true,
      },
    ]
  );
 
  async function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string;
 
    addOptimisticComment({ text });
    await addComment(articleId, text);
  }
 
  return (
    <div>
      <ul>
        {optimisticComments.map(comment => (
          <li
            key={comment.id}
            className={comment.pending ? 'opacity-50' : ''}
          >
            <strong>{comment.author}</strong>
            <p>{comment.text}</p>
            {comment.pending && <span className="text-xs">전송 중...</span>}
          </li>
        ))}
      </ul>
 
      <form action={handleSubmit}>
        <input name="text" placeholder="댓글을 입력하세요" required />
        <button type="submit">작성</button>
      </form>
    </div>
  );
}

복합 상태 패턴

여러 필드를 동시에 낙관적으로 업데이트하는 패턴입니다.

프로필 낙관적 업데이트
typescript
'use client';
 
import { useOptimistic, useTransition } from 'react';
import { updateProfile } from '@/actions/profile';
 
interface Profile {
  name: string;
  bio: string;
  avatar: string;
}
 
function ProfileEditor({ profile }: { profile: Profile }) {
  const [optimisticProfile, setOptimisticProfile] = useOptimistic(
    profile,
    (current: Profile, updates: Partial<Profile>) => ({
      ...current,
      ...updates,
    })
  );
  const [isPending, startTransition] = useTransition();
 
  function handleSubmit(formData: FormData) {
    const updates = {
      name: formData.get('name') as string,
      bio: formData.get('bio') as string,
    };
 
    startTransition(async () => {
      setOptimisticProfile(updates);
      await updateProfile(updates);
    });
  }
 
  return (
    <form action={handleSubmit}>
      <div>
        <label>이름</label>
        <input name="name" defaultValue={optimisticProfile.name} />
      </div>
      <div>
        <label>소개</label>
        <textarea name="bio" defaultValue={optimisticProfile.bio} />
      </div>
 
      <button type="submit" disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
    </form>
  );
}

세 훅의 조합

실전에서는 이 세 가지 훅을 함께 사용하는 경우가 많습니다.

세 훅 조합 예시
typescript
'use client';
 
import { useActionState, useOptimistic } from 'react';
import { useFormStatus } from 'react-dom';
import { submitReview } from '@/actions/review';
 
interface Review {
  id: string;
  rating: number;
  text: string;
  pending?: boolean;
}
 
// useFormStatus를 사용하는 자식 컴포넌트
function ReviewSubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? '등록 중...' : '리뷰 등록'}
    </button>
  );
}
 
function ReviewSection({
  productId,
  initialReviews,
}: {
  productId: string;
  initialReviews: Review[];
}) {
  // useActionState로 폼 상태 관리
  const [formState, formAction] = useActionState(
    async (prevState: { error: string | null }, formData: FormData) => {
      const rating = Number(formData.get('rating'));
      const text = formData.get('text') as string;
 
      // useOptimistic으로 즉시 UI 반영
      addOptimisticReview({ rating, text });
 
      const result = await submitReview(productId, { rating, text });
      if (!result.success) {
        return { error: result.error };
      }
      return { error: null };
    },
    { error: null }
  );
 
  // useOptimistic으로 낙관적 업데이트
  const [optimisticReviews, addOptimisticReview] = useOptimistic(
    initialReviews,
    (current: Review[], newReview: { rating: number; text: string }) => [
      {
        id: `temp-${Date.now()}`,
        ...newReview,
        pending: true,
      },
      ...current,
    ]
  );
 
  return (
    <div>
      {/* 리뷰 목록 */}
      <ul>
        {optimisticReviews.map(review => (
          <li key={review.id} className={review.pending ? 'opacity-60' : ''}>
            <span>{'★'.repeat(review.rating)}</span>
            <p>{review.text}</p>
          </li>
        ))}
      </ul>
 
      {/* 리뷰 작성 폼 */}
      <form action={formAction}>
        <select name="rating" required>
          {[5, 4, 3, 2, 1].map(n => (
            <option key={n} value={n}>{n}점</option>
          ))}
        </select>
        <textarea name="text" placeholder="리뷰를 작성해주세요" required />
        <ReviewSubmitButton />
        {formState.error && (
          <p className="text-red-500">{formState.error}</p>
        )}
      </form>
    </div>
  );
}

useFormState에서 useActionState로의 마이그레이션

React 18에서 react-dom의 useFormState를 사용하고 있었다면, React 19에서 react의 useActionState로 마이그레이션해야 합니다.

마이그레이션
typescript
// React 18 (deprecated)
import { useFormState } from 'react-dom';
const [state, formAction] = useFormState(action, initialState);
 
// React 19
import { useActionState } from 'react';
const [state, formAction, isPending] = useActionState(action, initialState);

주요 변경사항은 다음과 같습니다.

  • 패키지가 react-dom에서 react로 변경되었습니다.
  • 세 번째 반환값으로 isPending이 추가되었습니다.
  • 이름이 useFormState에서 useActionState로 변경되었습니다.

자동 마이그레이션 도구를 사용할 수 있습니다.

bash
npx codemod@latest react/19/replace-use-form-state

핵심 요약

  • useActionState는 비동기 액션의 결과 상태, 래핑된 액션 함수, 펜딩 상태를 반환하며 순차 실행을 보장합니다.
  • useFormStatus는 부모 <form>의 제출 상태를 읽으며, 반드시 자식 컴포넌트에서 호출해야 합니다.
  • useOptimistic은 비동기 작업 완료 전에 UI를 미리 업데이트하고, 실패 시 자동으로 롤백합니다.
  • 세 훅을 조합하면 로딩 상태, 에러 처리, 낙관적 업데이트를 모두 갖춘 폼을 구현할 수 있습니다.
  • useFormState(react-dom)는 deprecated되었으며, useActionState(react)로 마이그레이션해야 합니다.

다음 장에서는 Suspense의 고급 패턴과 스트리밍 SSR을 심층적으로 다룹니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#react#nextjs#performance#frontend#typescript

관련 글

웹 개발

6장: Suspense 고급 패턴과 스트리밍 SSR

React 19에서 강화된 Suspense의 고급 패턴, 스트리밍 SSR, 중첩 Suspense 전략, 배칭 동작, Partial Pre-rendering을 다룹니다.

2026년 2월 2일·16분
웹 개발

4장: use() API와 새로운 데이터 패턴

React 19의 use() API로 Promise와 Context를 조건부로 소비하는 방법, 서버-클라이언트 스트리밍 패턴, 기존 훅과의 차이를 다룹니다.

2026년 1월 29일·15분
웹 개발

7장: React Compiler - 자동 최적화의 시대

React Compiler의 동작 원리, HIR 기반 분석, 자동 메모이제이션, 설치와 설정, ESLint 통합, 실전 적용 전략을 다룹니다.

2026년 2월 4일·15분
이전 글4장: use() API와 새로운 데이터 패턴
다음 글6장: Suspense 고급 패턴과 스트리밍 SSR

댓글

목차

약 16분 남음
  • useActionState
    • 기본 시그니처
    • 기본 사용법
    • prevState 활용
    • 폼이 아닌 곳에서 사용
    • 순차 실행 보장
    • permalink을 통한 Progressive Enhancement
  • useFormStatus
    • 기본 시그니처
    • 핵심 규칙: 자식 컴포넌트에서 호출
    • 재사용 가능한 폼 컴포넌트
    • data 속성 활용
  • useOptimistic
    • 기본 시그니처
    • 작동 원리
    • 단순한 토글 패턴
    • 리스트 업데이트 패턴
    • 복합 상태 패턴
  • 세 훅의 조합
  • useFormState에서 useActionState로의 마이그레이션
  • 핵심 요약