React 19의 새로운 훅 3종을 심층 분석합니다. 폼 상태 관리, 제출 상태 추적, 낙관적 UI 업데이트의 실전 패턴을 다룹니다.
3장에서 Server Actions의 기본 개념을, 4장에서 use() API를 다루었습니다. 이번 장에서는 Server Actions와 함께 사용되도록 설계된 세 가지 새로운 훅을 심층적으로 살펴봅니다. useActionState로 액션 결과를 관리하고, useFormStatus로 폼 제출 상태를 추적하며, useOptimistic으로 즉각적인 UI 피드백을 제공하는 방법을 배웁니다.
useActionState는 폼 액션의 상태를 관리하는 훅입니다. useReducer와 유사하지만, 비동기 액션을 지원하고 폼의 action 속성과 직접 통합됩니다.
const [state, formAction, isPending] = useActionState(
action, // (prevState, payload) => Promise<newState>
initialState, // 초기 상태
permalink?, // Progressive Enhancement용 URL (선택)
);| 반환값 | 설명 |
|---|---|
state | 액션의 현재 결과 상태 |
formAction | <form action>에 전달할 래핑된 액션 함수 |
isPending | 액션이 실행 중인지 여부 |
'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 };
}
}'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>
);
}useActionState의 액션 함수는 첫 번째 인자로 이전 상태를 받습니다. 이를 활용하면 누적 상태를 관리할 수 있습니다.
'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을 직접 호출하면 됩니다.
'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의 중요한 특성은 여러 번 호출해도 순차적으로 실행된다는 것입니다. 사용자가 버튼을 빠르게 여러 번 클릭해도 각 호출이 이전 호출의 완료를 기다립니다.
// 사용자가 버튼을 3번 빠르게 클릭하면:
// 1번째 호출: prevState = { count: 0 } → { count: 1 }
// 2번째 호출: prevState = { count: 1 } → { count: 2 } (1번째 완료 후 실행)
// 3번째 호출: prevState = { count: 2 } → { count: 3 } (2번째 완료 후 실행)세 번째 인자 permalink은 JavaScript가 로드되기 전에 폼이 제출될 경우 브라우저가 이동할 URL을 지정합니다.
const [state, formAction, isPending] = useActionState(
subscribe,
{ message: '', success: false },
'/newsletter/subscribed' // JS 미로드 시 이 URL로 이동
);useFormStatus는 부모 <form>의 제출 상태를 읽는 훅입니다. react-dom에서 가져옵니다.
import { useFormStatus } from 'react-dom';
const { pending, data, method, action } = useFormStatus();| 속성 | 타입 | 설명 |
|---|---|---|
pending | boolean | 폼이 제출 중인지 여부 |
data | FormData | null | 제출된 폼 데이터 |
method | string | HTTP 메서드 (get/post) |
action | function | string | form의 action 속성값 |
useFormStatus는 반드시 <form> 내부의 자식 컴포넌트에서 호출해야 합니다. <form>을 렌더링하는 같은 컴포넌트에서는 동작하지 않습니다.
// 자식 컴포넌트로 분리
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '제출 중...' : '제출'}
</button>
);
}
// 폼에서 사용
function ContactForm() {
return (
<form action={submitAction}>
<input name="message" />
<SubmitButton /> {/* 자식 컴포넌트 */}
</form>
);
}function ContactForm() {
// 같은 컴포넌트에서 호출하면 항상 pending: false
const { pending } = useFormStatus();
return (
<form action={submitAction}>
<input name="message" />
<button disabled={pending}>제출</button>
</form>
);
}useFormStatus를 활용하면 범용적인 폼 UI 컴포넌트를 만들 수 있습니다.
'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>
);
}'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>
);
}function ArticleForm() {
return (
<form action={createArticle}>
<FormFields>
<input name="title" placeholder="제목" />
<textarea name="content" placeholder="내용" />
</FormFields>
<SubmitButton pendingText="저장 중...">저장</SubmitButton>
</form>
);
}data 속성으로 제출 중인 폼 데이터에 접근할 수 있습니다.
'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은 비동기 작업이 완료되기 전에 UI를 미리 업데이트하는 낙관적 업데이트(Optimistic Update) 패턴을 구현합니다.
const [optimisticValue, setOptimistic] = useOptimistic(
actualValue, // 실제 값 (서버 응답 후 적용될 값)
updateFn?, // (currentOptimistic, newValue) => updatedOptimistic
);'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 함수를 사용하면 리스트에 아이템을 추가/삭제하는 복잡한 낙관적 업데이트도 구현할 수 있습니다.
'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>
);
}여러 필드를 동시에 낙관적으로 업데이트하는 패턴입니다.
'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>
);
}실전에서는 이 세 가지 훅을 함께 사용하는 경우가 많습니다.
'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>
);
}React 18에서 react-dom의 useFormState를 사용하고 있었다면, React 19에서 react의 useActionState로 마이그레이션해야 합니다.
// 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로 변경되었습니다.자동 마이그레이션 도구를 사용할 수 있습니다.
npx codemod@latest react/19/replace-use-form-stateuseActionState는 비동기 액션의 결과 상태, 래핑된 액션 함수, 펜딩 상태를 반환하며 순차 실행을 보장합니다.useFormStatus는 부모 <form>의 제출 상태를 읽으며, 반드시 자식 컴포넌트에서 호출해야 합니다.useOptimistic은 비동기 작업 완료 전에 UI를 미리 업데이트하고, 실패 시 자동으로 롤백합니다.useFormState(react-dom)는 deprecated되었으며, useActionState(react)로 마이그레이션해야 합니다.다음 장에서는 Suspense의 고급 패턴과 스트리밍 SSR을 심층적으로 다룹니다.
이 글이 도움이 되셨나요?
React 19에서 강화된 Suspense의 고급 패턴, 스트리밍 SSR, 중첩 Suspense 전략, 배칭 동작, Partial Pre-rendering을 다룹니다.
React 19의 use() API로 Promise와 Context를 조건부로 소비하는 방법, 서버-클라이언트 스트리밍 패턴, 기존 훅과의 차이를 다룹니다.
React Compiler의 동작 원리, HIR 기반 분석, 자동 메모이제이션, 설치와 설정, ESLint 통합, 실전 적용 전략을 다룹니다.