React 19 Actions 실전 가이드: useActionState·useFormStatus·useOptimistic으로 라이브러리 없이 폼과 낙관적 UI 구현하기
React 18 시절, 폼 하나를 제대로 만들려면 isLoading, error, success 상태를 따로 선언하고, 제출 함수 안에서 try/catch로 감싸고, prop drilling 없이 버튼을 비활성화하려면 Context까지 끌어들이는 과정이 필요했습니다. 솔직히 이 보일러플레이트가 지겨워서 React Hook Form이나 Formik으로 달아나던 분들이 꽤 많았을 거라 생각합니다. 저도 그랬으니까요.
2024년 12월 React 19 안정 버전 출시로, useActionState로 비동기 액션의 상태를 단일 훅으로 묶고, useFormStatus로 자식 컴포넌트가 폼 상태를 직접 구독하고, useOptimistic으로 서버 응답 없이 UI를 즉시 업데이트하는 것이 React 코어만으로 가능해졌습니다. 2026년 중반 기준 Next.js·TanStack Start·Vite 등 주요 스타터들이 React 19를 기본값으로 채택하면서, 새로운 코드베이스에서 useActionState 코드를 처음 마주쳤을 때 해석이 어렵지 않으려면 이 패턴을 익혀두는 편이 좋습니다.
이 글에서는 세 훅의 작동 원리와 결합 방법, 그리고 실제 코드에서 마주치는 함정들을 짚어봅니다. Server Actions 환경(Next.js 15)과 순수 클라이언트 환경 모두를 다루기 때문에, 프레임워크 선택과 무관하게 참고할 수 있습니다.
핵심 개념
Actions — form의 action prop에 함수를 넣을 수 있게 됐습니다
React 19 이전에는 <form onSubmit={handleSubmit}>이 전부였습니다. React 19부터는 <form action={fn}>처럼 action이나 formAction prop에 비동기 함수를 직접 전달할 수 있습니다. 이때 React는 폼 제출을 트랜지션 안에서 처리하고, 성공 시 폼 값을 자동으로 리셋해 줍니다.
중요한 변화는 React 18과 19의 startTransition 동작 차이입니다. React 18에서는 startTransition이 비동기 콜백을 지원하지 않았습니다. 첫 번째 await 이후의 코드는 트랜지션 범위를 벗어나 실행되었기 때문에, await 시점에 isPending이 즉시 false로 바뀌었습니다. React 19는 비동기 트랜지션을 정식 지원하여 비동기 작업이 완전히 끝날 때까지 isPending이 true로 유지됩니다.
sequenceDiagram
participant 사용자
participant 폼
participant React
participant 서버
사용자->>폼: 제출 클릭
폼->>React: action 함수 호출
React->>React: isPending = true 시작
React->>서버: API 요청 전송
Note over React: React 18은 비동기 지원 부재로<br/>이 시점에 isPending = false
Note over React: React 19는 비동기 완료까지<br/>isPending = true 유지
서버-->>React: 응답 반환
React->>React: state 업데이트, 폼 리셋
React->>React: isPending = false
React-->>폼: 새 state로 리렌더useActionState — loading·error·success를 한 훅으로
const [state, dispatch, isPending] = useActionState(actionFn, initialState);actionFn은 (prevState, formData) => Promise<newState> 형태입니다. 이 훅의 첫 번째 규칙은 모든 코드 경로에서 동일한 형태의 state를 반환해야 한다는 것입니다. 성공·실패·검증 오류 어느 경우에도 같은 타입이어야 TypeScript와 React 모두 혼란 없이 처리할 수 있습니다.
저도 처음엔 성공 시에만 { success: true }, 실패 시엔 { error: '...' }처럼 다른 형태로 반환했다가 타입 에러와 UI 깜빡임을 동시에 경험했습니다. 초기 state 형태를 고정해 두고 모든 분기에서 그 구조를 채워서 반환하는 것이 안전합니다.
// 모든 분기에서 동일한 형태 반환
async function signupAction(
prevState: { error: string | null; success: boolean },
formData: FormData
) {
const name = formData.get("name") as string;
if (!name) {
return { error: "이름을 입력해 주세요.", success: false };
}
try {
await saveUser(name);
return { error: null, success: true };
} catch {
return { error: "서버 오류가 발생했습니다.", success: false };
}
}isPending은 세 번째 반환값으로, React 18의 실험적 useFormState에는 없던 기능입니다. 이 값을 활용하면 제출 중 버튼 비활성화, 스피너 표시 등을 추가 상태 없이 구현할 수 있습니다.
useFormStatus — prop drilling 없이 자식이 폼 상태를 구독
const { pending, data, method, action } = useFormStatus();인수가 없고, 반드시 <form> 요소의 자식 컴포넌트 안에서 호출해야 합니다. 같은 파일이 아니어도 되고, 트리 어딘가에 부모 <form>이 존재하기만 하면 됩니다. <form> 바깥에서 호출하면 pending이 항상 false가 됩니다 — 이게 꽤 흔한 함정입니다.
네 개의 반환값 중 실무에서 주로 쓰는 것은 pending입니다. data는 현재 제출 중인 FormData, method는 HTTP 메서드("get" 또는 "post"), action은 폼에 전달된 action 함수 레퍼런스입니다. 같은 레이아웃에 여러 폼이 있을 때 어떤 폼이 제출 중인지 구분하거나, 낙관적 업데이트에 제출 데이터를 바로 활용할 때 data가 유용합니다.
// 어떤 <form> 안에든 넣으면 자동으로 폼 상태를 구독합니다
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "처리 중..." : label}
</button>
);
}useOptimistic — 서버 응답을 기다리지 않고 UI를 즉시 업데이트
const [optimisticState, addOptimistic] = useOptimistic(
actualState,
(currentState, optimisticValue) => newState
);두 번째 인수가 핵심입니다. addOptimistic(value)를 호출하면, 이 value가 두 번째 함수의 optimisticValue로 들어가서 즉시 새 상태를 계산합니다. 트랜지션이 완료되면 optimisticState는 실제 actualState로 자동으로 해소됩니다.
중요한 전제 조건이 하나 있습니다. addOptimistic은 반드시 트랜지션 컨텍스트 안에서 호출해야 합니다. <form action={fn}>에 함수를 전달하면 React가 자동으로 트랜지션을 시작하기 때문에 아래 예시들이 동작하는 것이고, 일반 onClick 핸들러 안에서 호출하면 경고와 함께 기대한 대로 동작하지 않습니다. onClick을 써야 한다면 startTransition으로 감싸야 합니다.
또 한 가지: 자동 롤백은 제공되지 않습니다. API가 실패하면 actualState가 변하지 않은 채로 트랜지션이 끝나므로, 낙관적으로 보여줬던 내용이 원래 상태로 조용히 되돌아갑니다. 실패했다는 사실을 사용자에게 알려주는 별도 처리가 필요합니다.
실전 적용
기본 폼 제출 — 가입 폼
세 훅이 처음 협력하는 모습을 봐보겠습니다. useActionState가 상태를 관리하고, useFormStatus가 버튼에 로딩 상태를 전달합니다.
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
type FormState = {
error: string | null;
success: boolean;
};
const initialState: FormState = { error: null, success: false };
async function signupAction(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
if (!name || !email) {
return { error: "모든 필드를 입력해 주세요.", success: false };
}
try {
await registerUser({ name, email });
return { error: null, success: true };
} catch {
return { error: "가입 중 오류가 발생했습니다.", success: false };
}
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "가입 처리 중..." : "가입하기"}
</button>
);
}
export function SignupForm() {
const [state, dispatch] = useActionState(signupAction, initialState);
if (state.success) {
return <p>가입이 완료되었습니다!</p>;
}
return (
<form action={dispatch}>
<input name="name" type="text" placeholder="이름" required />
<input name="email" type="email" placeholder="이메일" required />
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
<SubmitButton />
</form>
);
}SubmitButton이 별도 컴포넌트로 분리된 이유가 있습니다. useFormStatus는 자신이 렌더링된 컴포넌트의 부모 <form>을 구독하기 때문에, SignupForm 내부에서 직접 호출하면 동작하지 않습니다. 이 분리는 선택이 아니라 필수입니다.
좋아요/취소 버튼 — useOptimistic으로 즉각적인 피드백
import { useState } from "react";
import { useOptimistic } from "react";
async function toggleLike(
postId: string
): Promise<{ count: number; isLiked: boolean }> {
const res = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
return res.json();
}
export function LikeButton({
postId,
initialLikes,
initialIsLiked,
}: {
postId: string;
initialLikes: number;
initialIsLiked: boolean;
}) {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(currentLikes: number, delta: number) => currentLikes + delta
);
// <form action={...}>이 트랜지션을 열어주므로 addOptimisticLike 호출이 동작합니다
async function handleLike() {
const delta = isLiked ? -1 : 1;
addOptimisticLike(delta);
try {
const updated = await toggleLike(postId);
setLikes(updated.count);
setIsLiked(updated.isLiked);
} catch {
// actualState(likes)가 변하지 않으므로 optimisticLikes가 자동으로 복구됩니다
// 실제 서비스에서는 에러 토스트 등 사용자 알림을 추가하세요
}
}
return (
<form action={handleLike}>
<button type="submit">
{optimisticLikes} {isLiked ? "❤️" : "🤍"}
</button>
</form>
);
}addOptimisticLike(delta)에서 delta가 두 번째 인수 함수 (currentLikes, delta) => currentLikes + delta로 들어가 즉시 계산됩니다. 좋아요 상태에 따라 +1 또는 -1을 전달하고, 서버 응답이 오면 실제 count 값으로 확정합니다.
Server Actions + useActionState — Next.js 15 포스트 작성
Next.js 환경에서는 서버 함수를 바로 useActionState에 연결할 수 있습니다. JS가 hydrate되기 전에도 HTML 기본 동작으로 폼 제출이 작동하는 Progressive Enhancement를 지원한다는 점이 큰 장점입니다.
// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
type PostFormState = {
error: string | null;
success: boolean;
};
export async function createPostAction(
prevState: PostFormState,
formData: FormData
): Promise<PostFormState> {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
if (!title?.trim()) {
return { error: "제목을 입력해 주세요.", success: false };
}
try {
await db.post.create({ data: { title, content } });
revalidatePath("/posts");
return { error: null, success: true };
} catch {
return { error: "포스트 저장에 실패했습니다.", success: false };
}
}// app/posts/PostForm.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createPostAction } from "./actions";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "저장 중..." : "발행하기"}
</button>
);
}
export default function PostForm() {
const [state, action] = useActionState(createPostAction, {
error: null,
success: false,
});
return (
<form action={action}>
<input name="title" type="text" placeholder="제목" />
<textarea name="content" placeholder="내용" />
{state.error && <p>{state.error}</p>}
{state.success && <p>포스트가 발행되었습니다!</p>}
<SubmitButton />
</form>
);
}댓글 목록 — useOptimistic + useActionState 결합
두 훅을 함께 쓰면 "즉각적인 화면 반영 + 서버 상태 동기화 + 실패 시 복구 + 에러 표시"를 한 번에 처리할 수 있습니다.
state 타입 설계가 중요합니다. 여기서는 Comment[]를 단순히 state로 쓰지 않고 { comments: Comment[]; error: string | null } 형태로 감쌌습니다. useActionState 절에서 강조한 "모든 분기에서 동일한 형태 반환" 원칙을 지키면서, 동시에 실패 시 에러 메시지를 렌더까지 전달할 수 있는 채널이 생깁니다.
import { useActionState, useOptimistic } from "react";
import { useFormStatus } from "react-dom";
type Comment = { id: string; text: string; author: string };
type CommentState = { comments: Comment[]; error: string | null };
async function postComment(text: string): Promise<Comment> {
const res = await fetch("/api/comments", {
method: "POST",
body: JSON.stringify({ text }),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error("댓글 저장 실패");
return res.json();
}
async function addCommentAction(
prevState: CommentState,
formData: FormData
): Promise<CommentState> {
const text = formData.get("text") as string;
try {
const newComment = await postComment(text);
return { comments: [...prevState.comments, newComment], error: null };
} catch {
return { comments: prevState.comments, error: "댓글 등록에 실패했습니다." };
}
}
function CommentSubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "등록 중..." : "댓글 달기"}
</button>
);
}
export function CommentSection({
initialComments,
}: {
initialComments: Comment[];
}) {
const [state, dispatch] = useActionState(addCommentAction, {
comments: initialComments,
error: null,
});
const [optimisticComments, addOptimistic] = useOptimistic(
state.comments,
(current: Comment[], newComment: Comment) => [...current, newComment]
);
// <form action={dispatch}>에 바로 연결하면 addOptimistic을 호출할 기회가 없습니다.
// 이 래퍼 함수는 <form action={...}>이 열어주는 트랜지션 안에서 실행되므로
// addOptimistic 호출이 유효하고, 이어서 dispatch(formData)가 실제 액션을 트리거합니다.
// 임시 댓글(temp-*)은 addCommentAction이 완료되어 state.comments가 갱신될 때까지 유지됩니다.
async function handleSubmit(formData: FormData) {
const text = formData.get("text") as string;
addOptimistic({ id: `temp-${Date.now()}`, text, author: "나" });
dispatch(formData);
}
return (
<div>
<ul>
{optimisticComments.map((c) => (
<li
key={c.id}
style={{ opacity: c.id.startsWith("temp-") ? 0.6 : 1 }}
>
<strong>{c.author}</strong>: {c.text}
</li>
))}
</ul>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
<form action={handleSubmit}>
<input name="text" type="text" placeholder="댓글을 입력하세요" />
<CommentSubmitButton />
</form>
</div>
);
}임시 댓글에 opacity: 0.6을 주는 것은 사용자가 "아직 서버에 저장되지 않은 항목"임을 시각적으로 인지할 수 있게 해주는 작은 배려입니다. 실패하면 state.error가 채워지고 state.comments는 그대로이므로, 임시 댓글이 목록에서 사라지고 에러 메시지가 표시됩니다.
장단점 분석
도입했을 때 얻는 것
| 항목 | 내용 |
|---|---|
| 보일러플레이트 감소 | isLoading, error, success 상태를 useActionState 하나로 통합 |
| prop drilling 제거 | useFormStatus로 자식이 부모 폼 상태를 직접 구독 |
| 즉각적인 UX | useOptimistic으로 네트워크 지연과 무관하게 UI 즉시 반영 |
| 서버 통합 | Server Functions와 결합 시 Progressive Enhancement 지원 |
| 외부 의존성 없음 | 단순 폼은 React 코어만으로 완결 |
| 자동 폼 리셋 | action prop 사용 시 제출 성공 후 폼이 자동 초기화 |
제한사항과 주의점
| 항목 | 내용 |
|---|---|
useFormStatus 위치 제약 |
반드시 부모 <form> 자식 컴포넌트 안에서 호출해야 함 |
| 트랜지션 컨텍스트 필요 | addOptimistic은 form action 또는 startTransition 안에서만 동작 |
| 수동 에러 처리 | useOptimistic은 자동 롤백 없음, 실패 시 직접 처리 필요 |
| state 형태 일관성 | useActionState 액션은 모든 분기에서 동일한 타입 반환 필수 |
| 낙관적 업데이트 적합성 | 결제·삭제 같은 비가역 작업이나 실패율 높은 시나리오에는 부적합 |
| 복잡한 폼 | 다단계 폼, 실시간 검증, 중첩 필드는 RHF / TanStack Form 병용이 효율적 |
| Server Actions 의존성 | Next.js 등 프레임워크 지원이 필요, 순수 클라이언트 환경에서는 일반 async 함수로 대체 |
어떤 폼에 네이티브 Actions를 쓸지 고민될 때
flowchart TD
A[새 폼 개발 시작] --> B{복잡도}
B -->|단순 3~5개 필드| C[네이티브 Actions 검토]
B -->|복잡한 다단계 폼| D[RHF 또는 TanStack Form]
C --> E{실시간 검증 필요?}
E -->|아니오| F[useActionState + useFormStatus]
E -->|예| G[RHF와 useActionState 병용]
F --> H{낙관적 UI 필요?}
H -->|예| I[useOptimistic 추가]
H -->|아니오| J[완성]
I --> J
G --> J
D --> J실무에서 자주 만나는 실수
useFormStatus를 같은 컴포넌트에서 호출하는 경우. <form>과 같은 레벨에 있으면 동작하지 않습니다. 반드시 자식 컴포넌트로 분리해야 합니다.
addOptimistic을 일반 onClick 핸들러에서 호출하는 경우. 트랜지션 컨텍스트가 없으므로 경고가 발생하고 낙관적 업데이트가 동작하지 않습니다. onClick 안에서 사용하려면 startTransition으로 감싸야 합니다.
useActionState 액션에서 분기마다 다른 타입을 반환하는 경우. 성공 시 string, 실패 시 null을 반환하면 TypeScript 에러뿐 아니라 예기치 않은 UI 동작이 생깁니다. 초기값 형태를 스키마처럼 고정해 두고 모든 경로에서 그 형태를 채우는 것이 안전합니다.
낙관적 업데이트를 비가역적 작업에 적용하는 경우. 결제·영구 삭제 같은 작업에서 낙관적 반영 후 실패하면 사용자에게 혼란을 줄 수 있습니다. 실패율이 낮고 쉽게 재시도 가능한 작업에만 적용하는 것을 권장합니다.
마치며
세 훅이 각자 해결하는 문제는 명확합니다. useActionState가 비동기 상태를 단일 훅으로 묶어 loading·error·success를 위한 별도 useState를 없애고, useFormStatus가 prop 없이 자식이 폼 상태를 구독하게 하고, useOptimistic이 서버 응답 전에 UI를 즉각 반응하게 만듭니다. 세 훅은 독립적으로도 쓸 수 있지만, 함께 쓸 때 각자의 제약을 서로 보완합니다.
모든 것을 한 번에 바꿀 필요는 없습니다. 지금 당장 시작한다면 이 순서를 추천합니다.
1단계 — useActionState로 기존 폼 상태를 통합해 보기. useState 여러 개로 관리하던 loading·error·success를 하나로 묶어 보는 것부터 시작하면 변화가 바로 느껴집니다.
2단계 — Submit 버튼을 useFormStatus 컴포넌트로 분리하기. Context 없이 폼 상태가 자식까지 흐르는 구조를 직접 경험해 보면, 이후 새 폼을 만들 때 자연스럽게 이 패턴이 먼저 떠오릅니다.
3단계 — 좋아요, 팔로우처럼 단순하고 빠른 액션에 useOptimistic 적용해 보기. 실패율이 낮고 재시도 가능한 작업부터 시작해 낙관적 UI의 감을 잡은 뒤, 점진적으로 복잡한 시나리오로 확장하는 것이 안전합니다.
참고 자료
- React v19 공식 릴리즈 노트 — react.dev
- useActionState 공식 API 문서 — react.dev
- useOptimistic 공식 API 문서 — react.dev
- Server Functions 공식 문서 — react.dev
- React 19 Deep Dive — Forms & Actions — DEV Community
- useActionState in React: A practical guide — LogRocket Blog
- React 19 useOptimistic Deep Dive — DEV Community
- React 19 Actions — FreeCodeCamp
- How to Use the Optimistic UI Pattern — FreeCodeCamp
- React 19 in Mid-2026: Trends, Patterns & Migration — Techglock
- State of React 2025-2026 Key Takeaways — Strapi
- Composable Form Handling in 2025 — Makers' Den
- React 18 to 19 Migration Complete Guide — Bacancy
- Exploring New Hooks in React 19 — Manuel Sanchez Dev
- Deep Dive React 19 Hooks — Medium