
data fetching 하나 하려고 하면 참 많은 상태들이 필요합니다.
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSuccess, setIsSuccess] = useState(false);
const [data, setData] = useState(null);
const [retryCount, setRetryCount] = useState(0);
그런데 이것들, 다 어디 갔을까요?
React 19의 useActionState 하나로 퉁쳤습니다!
오늘은 이 훅으로 6개 상태 × 3개 응답 × 24시간 타임아웃이 얽힌 상태를
어떻게 구현했는지 깊게 파보려고 해요.
간단한 지원하기 버튼을 생각해봅시다.
<button onClick={() => submitApplication()}>지원하기</button>
이렇게 이벤트 핸들러를 달아줄 수 있겠죠.
하지만 현실은 이렇습니다
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSuccess, setIsSuccess] = useState(false);
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
try {
await submitApplication();
setIsSuccess(true);
} catch (e) {
setError(e.message);
} finally {
setIsLoading(false);
}
};
3개의 useState, try-catch, 그리고 곧잘 빼먹는 finally까지..
근데 이거... 더 복잡해지면 어떡하죠?
진짜 문제는 이겁니다.
이번에 진행하는 프로젝트에서는 이런 상황이 있었거든요
이걸 useState로만 관리하면
컨트롤하기 어려운 여러 개의 state를 관리해야 할 수도 있습니다.
React 19에서 useActionState가 등장했습니다.
⚠️ 잠깐!
useFormState랑 헷갈리지 마세요.
React 19에서useFormState는 deprecated되고useActionState로 대체되었습니다.
const [state, formAction, isPending] = useActionState(action, initialState);
| 반환값 | 설명 |
|---|---|
state | 액션 실행 후의 상태 (서버에서 반환한 값) |
formAction | <form action={formAction}>에 바인딩할 함수 |
isPending | -> React 19에서 추가! 액션 실행 중 여부 |
세 번째 반환값 isPending이 핵심입니다.
이전에는 useFormStatus를 따로 써야 했거든요.
// features/application/ui/ApplyButton.tsx
"use client";
import { useActionState } from "react";
import { applyJob } from "../actions/apply-job";
export const ApplyButton = ({ jobId }: { jobId: string }) => {
const [state, formAction, isPending] = useActionState(applyJob, {
message: "",
success: false,
});
return (
<form action={formAction}>
<input type="hidden" name="jobPostingId" value={jobId} />
<button type="submit" disabled={isPending}>
{isPending ? "처리 중..." : "지원하기"}
</button>
{state.message && (
<p className={state.success ? "text-green-600" : "text-red-600"}>
{state.message}
</p>
)}
</form>
);
}
여기서 주목할 점:
isPending이 알아서 해줌state 하나로 통합 - 서버에서 반환한 그대로useActionState는 Server Action과 잘 붙습니다.
// features/application/actions/apply-job.ts
"use server";
import { db } from "@/shared/lib/db";
import { auth } from "@/shared/lib/auth";
type FormState = {
message: string;
success: boolean;
};
export const applyJob = async (
prevState: FormState, // 👈 이전 상태를 받음
formData: FormData
): Promise<FormState> => {
const session = await auth();
if (!session?.user) {
return { success: false, message: "로그인이 필요합니다." };
}
const jobPostingId = formData.get("jobPostingId") as string;
try {
await db.application.create({
data: {
jobPostingId,
workerId: session.user.workerId,
status: "PENDING",
},
});
return { success: true, message: "지원이 완료되었습니다." };
} catch (e) {
return { success: false, message: "지원 중 오류가 발생했습니다." };
}
};
Server Action의 특징:
"use server" 지시어로 선언prevState를 받음 (이게 핵심!)상태 머신은 시스템이 가질 수 있는 상태들과 그 상태 간의 전이 규칙을 정의한 모델입니다!
주로 시스템의 동작을 모델링 하는 데 사용되는데요,
지하철 개찰구를 예시로 든다면 아래를 예시로 들 수 있겠네요!
[잠김] ──(카드 태그)──▶ [열림] ──(통과)──▶ [잠김]
│ │
└──(카드 없이 밀기)──▶ [경고음] ──▶ [잠김]
- 상태: 잠김, 열림, 경고음
- 전이: 특정 조건에서만 다른 상태로 이동
- 규칙: "열림" 상태에서만 통과 가능
제가 구현한 시스템의 플로우입니다:
[근로자가 5개 공고에 묶음 지원]
↓
[호텔들이 개별 심사] → 3개 승인, 2개 거절
↓
[부분 승인 발생!] → 알림 발송 + 24시간 타이머 시작
↓
[근로자 선택]
├─ "승인된 3개만 할게요" (ACCEPT_PARTIAL)
└─ "아뇨, 전부 취소할게요" (REJECT_ALL)
↓
[24시간 경과 여부에 따라 패널티 분기]
이게 왜 복잡하냐면요,
| 요소 | 경우의 수 |
|---|---|
| Application 상태 | PENDING, ACCEPTED, REJECTED, CANCELED_BY_USER, CANCELED_SUBSTITUTED, NO_SHOW, COMPLETED (7개) |
| Bundle 응답 상태 | PENDING, ACCEPT_PARTIAL, REJECT_ALL (3개) |
| 시간 조건 | 24시간 이내 / 이후 |
| 패널티 계산 | 근무일까지 남은 일수에 따라 차등 |
useState로 관리하기 맛만 한 번 같이 보실까요?
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applications, setApplications] = useState(bundle.applications);
const [bundleStatus, setBundleStatus] = useState(bundle.partialApprovalResponse);
const [hasResponded, setHasResponded] = useState(false);
const [isPenaltyFree, setIsPenaltyFree] = useState(true);
const [remainingTime, setRemainingTime] = useState(0);
const [penaltyAmount, setPenaltyAmount] = useState(0);
...
state 선언을 시작으로 useEffect까지 정말 덕지덕지 코드를 붙여야합니다.
이렇게 여러 개의 state를 관리하면 특히 동기화 상태를 관리하기 어렵습니다.
값이 하나만 변경되거나 이럴 때요!
그리고 클라이언트의 상태 전이 로직을 사용하는게 아니라
서버에서의 상태 전이 로직을 그대로 갖다가 쓸 수 있는 것이 큰 매력입니다.
"use server";
export const respondPartialApproval = async (prevState, formData) => {
const bundleId = formData.get("bundleId");
const response = formData.get("response"); // "ACCEPT_PARTIAL" | "REJECT_ALL"
// 1. 현재 상태 조회
const bundle = await db.applicationBundle.findUnique({ where: { id: bundleId } });
// 2. 전이 가드 - PENDING 아니면 차단
if (bundle.partialApprovalResponse !== "PENDING") {
return { success: false, message: "이미 응답 완료" };
}
// 3. 조건 체크 (24시간 이내인지)
const isPenaltyFree = isWithinPenaltyFreeWindow(bundle.partialApprovalNotifiedAt);
// 4. 트랜잭션으로 원자적 전이
await db.$transaction(async (tx) => {
await tx.applicationBundle.update({ ... }); // 상태 변경
if (response === "REJECT_ALL" && !isPenaltyFree) {
// 패널티 계산 + 적용
}
});
// 5. 결과 반환
return { success: true, message: "처리 완료" };
};
이게 왜 **상태 머신**인지 보이시나요?
1. **현재 상태 조회** → `bundle.partialApprovalResponse`
2. **전이 가드** → `PENDING`일 때만 전이 가능
3. **조건부 분기** → 24시간 조건, 응답 타입에 따른 분기
4. **원자적 전이** → `$transaction`으로 중간 상태 없이 전이
5. **부수 효과 처리** → 패널티 계산, 알림 발송 등
이런 상태 머신은 다양한 장점을 가지고 있습니다.
1. 불가능한 상태를 원천 차단
```typescript
//❌ useState - 버그 가능
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
// 실수로 이런 상태가 될 수 있음
{ isLoading: true, isSuccess: true, isError: true } // ???
//✅ 상태 머신 - 불가능
// 동시에 두 상태일 수 없음
type State = "idle" | "loading" | "success" | "error";
//❌ useState - 아무 곳에서나 아무 상태로 변경 가능
setIsApproved(true); // PENDING에서? REJECTED에서? 아무 때나?
✅ 상태 머신 - 허용된 전이만 가능
// PENDING → APPROVED ✅
// PENDING → REJECTED ✅
// REJECTED → APPROVED ❌ (규칙에 없음)
//❌ useState - 어디서 뭐가 바뀌었는지 추적 어려움
// "왜 isLoading이 true인데 isError도 true지?"
//✅ 상태 머신 - 전이 로그만 보면 됨
// idle → loading → error
// 경로가 명확함
//❌ useState - 로직이 여기저기 흩어짐
// ApplyButton.tsx에서 setStatus("pending")
// NotificationHandler.tsx에서 setStatus("approved")
// TimeoutChecker.tsx에서 setStatus("expired")
//✅ 상태 머신 - 전이 로직이 Server Action 한 곳에!
export const updateApplicationStatus = async (prevState, formData) => {
// 모든 전이 규칙이 여기에
}
상태 머신 테스트
test("PENDING에서 APPROVE하면 APPROVED가 된다", () => {
const result = transition("PENDING", "APPROVE");
expect(result).toBe("APPROVED");
});
test("REJECTED에서 APPROVE하면 에러", () => {
const result = transition("REJECTED", "APPROVE");
expect(result.error).toBe("불가능한 전이");
});
"use client";
export const BundleResponseClient = ({ bundle }) => {
const [state, formAction, isPending] = useActionState(
respondPartialApproval,
{ message: "", success: false }
);
return (
<div>
{state.message && <p>{state.message}</p>}
{/* 두 버튼이 같은 formAction 공유 */}
<form action={formAction}>
<input type="hidden" name="bundleId" value={bundle.id} />
<input type="hidden" name="response" value="ACCEPT_PARTIAL" />
<button disabled={isPending}>승인된 근무 진행</button>
</form>
<form action={formAction}>
<input type="hidden" name="bundleId" value={bundle.id} />
<input type="hidden" name="response" value="REJECT_ALL" />
<button disabled={isPending}>전체 취소</button>
</form>
</div>
);
}
클라이언트 코드에서 주목할 점:
useState 하나도 없어요bundle.partialApprovalResponse)에 따른 분기오.. 근데 이거 내부적으로 어떻게 돌아가는 건가요?
React 19 소스코드를 까보면 대략 이런 구조입니다
// 단순화한 useActionState 내부 구현
function useActionState<State>(
action: (prevState: State, formData: FormData) => Promise<State>,
initialState: State
): [State, (formData: FormData) => void, boolean] {
const [state, setState] = useState(initialState);
const [isPending, startTransition] = useTransition();
const formAction = useCallback(
(formData: FormData) => {
startTransition(async () => {
// ⭐️ 이전 상태를 action에 전달
const newState = await action(state, formData);
setState(newState);
});
},
[action, state]
);
return [state, formAction, isPending];
}
핵심 포인트는 이렇습니다.
| 개념 | 설명 |
|---|---|
| 상태 누적 | action(state, formData)로 이전 상태 전달 → 상태 머신 구현 가능 |
| Transition | startTransition으로 감싸져 있음 → UI 차단 없이 비동기 처리 |
| 자동 업데이트 | 액션 완료 시 setState(newState) 자동 호출 |
// ❌ 예전 방식 (useFormStatus 필요)
const SubmitButton = () => {
const { pending } = useFormStatus(); // 👈 별도 훅 필요
return <button disabled={pending}>제출</button>;
}
// ✅ React 19 방식
const Form = () => {
const [state, formAction, isPending] = useActionState(action, init);
// ^^^^^^^^^ 여기서 바로 받음
return (
<form action={formAction}>
<button disabled={isPending}>제출</button>
</form>
);
}
isPending이 직접 반환되니까 컴포넌트 분리 없이 로딩 상태를 쓸 수 있어요.
const [state, formAction, isPending] = useActionState(submitForm, {
message: "",
success: false,
});
// 같은 컴포넌트에서 지원/취소 두 가지 액션
const [applyState, applyAction] = useActionState(applyJob, initialState);
const [cancelState, cancelAction] = useActionState(cancelApplication, initialState);
// Server Action에서 상태 전이 로직 구현
export const respondPartialApproval = async (prevState, formData) => {
// 1. 현재 상태 조회
// 2. 전이 가드 체크
// 3. 조건부 분기
// 4. 트랜잭션으로 원자적 전이
// 5. 새 상태 반환
};
import { useOptimistic } from "react";
const TodoList = ({ todos }) => {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo) => [...state, { ...newTodo, pending: true }]
);
const [state, formAction] = useActionState(async (prev, formData) => {
addOptimisticTodo({ text: formData.get("text") });
return await createTodo(formData);
}, null);
// optimisticTodos를 렌더링
}
저는
react-hook-form을 쓰고 있는데useActionState로 갈아타는 것이 고민이에요..
// react-hook-form 방식
const RegisterForm = () => {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<button disabled={isSubmitting}>가입</button>
</form>
);
}
강점:
errors.email, errors.password 개별 접근// useActionState 방식
"use client";
const RegisterForm = () => {
const [state, formAction, isPending] = useActionState(registerAction, { errors: {} });
return (
<form action={formAction}>
<input name="email" />
{state.errors?.email && <span>{state.errors.email}</span>}
<button disabled={isPending}>가입</button>
</form>
);
}
// Server Action
"use server";
export const registerAction = async (prevState, formData) => {
// 1. 검증
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) return { errors: result.error.flatten().fieldErrors };
// 2. 중복 체크 (DB 의존 검증 - 서버에서만 가능)
if (await db.user.findUnique({ where: { email: result.data.email } })) {
return { errors: { email: ["이미 사용 중"] } };
}
// 3. 저장
await db.user.create({ data: result.data });
return { success: true };
}
강점:
| 관점 | react-hook-form | useActionState |
|---|---|---|
| 검증 위치 | 클라이언트 | 서버 (+ 클라이언트 옵션) |
| 에러 피드백 | 실시간 (onChange) | 제출 후 (onSubmit) |
| DB 의존 검증 | 별도 API 필요 | Server Action에서 직접 |
| 복잡한 폼 UI | ⭐ 최적화됨 | 직접 관리 필요 |
실시간 피드백 필요?
│
┌──── Yes ─┴─ No ────┐
▼ ▼
react-hook-form 서버 검증 필요?
│ │
│ ┌── Yes ─┴─ No ──┐
│ ▼ ▼
│ useActionState useState
│ │
│ │
▼ ▼
┌─────────────────────────────────┐
둘 다 쓸 수도 있죠!
rhf + Server Action으로
사용도 가능해요
└─────────────────────────────────┘
이 프로젝트의 폼들을 보면
| 폼 | 특징 | 선택 |
|---|---|---|
| 회원가입 | 아이디 중복 체크 필요 | useActionState ✅ |
| 공고 등록 | 크레딧 잔액 체크 필요 | useActionState ✅ |
| 부분 승인 응답 | 서버 시간 기준 타이머 | useActionState ✅ |
거의 모든 폼이 서버 상태에 의존하거나 상태 전이가 필요했어요.
이런 상황에서 react-hook-form으로
클라이언트 검증 → API 호출 → 에러 처리하는 것보다
Server Action 하나로 끝내는 게 깔끔했습니다.
| 상황 | 추천 |
|---|---|
| 단순 폼 제출 (로그인, 검색) | ✅ 적합 |
| CRUD 작업 | ✅ 적합 |
| 복잡한 상태 전이 (결제, 예약) | ✅ 매우 적합 |
| 실시간 데이터 (채팅, 알림) | ❌ WebSocket/SSE 사용 |
| 클라이언트만의 상태 | ❌ useState 사용 |
useActionState 동작원리를 뜯어보며 실제 프로젝트에 적용했다는 점이 긍정적이었어요.
react-hook-form과의 조합도 충분히 재밌을 것 같습니다!
그리고 무엇보다도 상태 머신이란 개념으로 상태를 강제하는 측면이 프로젝트의 선명도를 높여주는 느낌이었어요.
제가 구현한 "묶음 지원 → 부분 승인 → 24시간 응답" 시스템은 22개 파일에서 이 패턴을 활용했어요.
useState 지옥에서 벗어나 상태 전이를 서버에 위임하니까
클라이언트 코드가 놀랍도록 단순해졌습니다.
React 19를 쓰고 계시다면, 복잡한 폼과 상태를 관리할 때
useActionState를 고려해보시는 것, 어떠신가요?!