그 많던 useState는 누가 다 먹었을까

Kyle·2025년 12월 27일

React

목록 보기
4/5
post-thumbnail

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까지..

근데 이거... 더 복잡해지면 어떡하죠?

진짜 문제는 이겁니다.
이번에 진행하는 프로젝트에서는 이런 상황이 있었거든요

  1. 지원 → 승인/거절 → 부분 승인 → 24시간 내 응답 필요
  2. 응답 안 하면? → 패널티 계산 로직 분기
  3. 응답하면? → 다시 상태 분기

이걸 useState로만 관리하면
컨트롤하기 어려운 여러 개의 state를 관리해야 할 수도 있습니다.


useActionState의 등장

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>
  );
}

여기서 주목할 점:

  1. 로딩 상태 관리가 사라짐 - isPending이 알아서 해줌
  2. 에러/성공 분기가 state 하나로 통합 - 서버에서 반환한 그대로

useActionStateServer 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" 지시어로 선언
  • 서버에서만 실행됨 (클라이언트 번들에 포함 안 됨)
  • DB 접근, 인증 체크 등 서버 로직을 직접 작성
  • 첫 번째 인자로 prevState를 받음 (이게 핵심!)

state에서 상태 머신으로의 확장

상태 머신은 시스템이 가질 수 있는 상태들과 그 상태 간의 전이 규칙을 정의한 모델입니다!
주로 시스템의 동작을 모델링 하는 데 사용되는데요,
지하철 개찰구를 예시로 든다면 아래를 예시로 들 수 있겠네요!

  [잠김] ──(카드 태그)──▶ [열림] ──(통과)──▶ [잠김]
     │                       │
     └──(카드 없이 밀기)──▶ [경고음] ──▶ [잠김]

  - 상태: 잠김, 열림, 경고음
  - 전이: 특정 조건에서만 다른 상태로 이동
  - 규칙: "열림" 상태에서만 통과 가능

시나리오: 묶음 지원 + 부분 승인 시스템

제가 구현한 시스템의 플로우입니다:

[근로자가 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를 관리하면 특히 동기화 상태를 관리하기 어렵습니다.
값이 하나만 변경되거나 이럴 때요!
그리고 클라이언트의 상태 전이 로직을 사용하는게 아니라
서버에서의 상태 전이 로직을 그대로 갖다가 쓸 수 있는 것이 큰 매력입니다.

해결: Server Action을 상태 머신처럼 사용

  "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";
  1. 전이 규칙이 명확함
  //❌ useState - 아무 곳에서나 아무 상태로 변경 가능
  setIsApproved(true);  // PENDING에서? REJECTED에서? 아무 때나?

  ✅ 상태 머신 - 허용된 전이만 가능
  // PENDING → APPROVED ✅
  // PENDING → REJECTED ✅
  // REJECTED → APPROVED ❌ (규칙에 없음)
  1. 디버깅이 쉬움
  //❌ useState - 어디서 뭐가 바뀌었는지 추적 어려움
  // "왜 isLoading이 true인데 isError도 true지?"

  //✅ 상태 머신 - 전이 로그만 보면 됨
  // idle → loading → error
  // 경로가 명확함
  1. 비즈니스 로직이 한 곳에
  //❌ useState - 로직이 여기저기 흩어짐
  // ApplyButton.tsx에서 setStatus("pending")
  // NotificationHandler.tsx에서 setStatus("approved")
  // TimeoutChecker.tsx에서 setStatus("expired")

  //✅ 상태 머신 - 전이 로직이 Server Action 한 곳에!
  export const updateApplicationStatus = async (prevState, formData) => {
    // 모든 전이 규칙이 여기에
  }
  1. 테스트하기 쉬움
  상태 머신 테스트
  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>
  );
}

클라이언트 코드에서 주목할 점:

  1. 상태 관리가 없음 - useState 하나도 없어요
  2. isPending 하나로 로딩 처리 - 두 폼이 같은 상태 공유
  3. state.message로 결과 표시 - 서버에서 내려준 그대로
  4. 조건부 UI - 서버 상태(bundle.partialApprovalResponse)에 따른 분기

내부 동작 원리: useActionState는 어떻게 작동하나?요?

오.. 근데 이거 내부적으로 어떻게 돌아가는 건가요?

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)로 이전 상태 전달 → 상태 머신 구현 가능
TransitionstartTransition으로 감싸져 있음 → UI 차단 없이 비동기 처리
자동 업데이트액션 완료 시 setState(newState) 자동 호출

useFormStatus와의 차이

// ❌ 예전 방식 (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이 직접 반환되니까 컴포넌트 분리 없이 로딩 상태를 쓸 수 있어요.


패턴 정리: useActionState 활용법

패턴 1: 기본 폼 제출

const [state, formAction, isPending] = useActionState(submitForm, {
  message: "",
  success: false,
});

패턴 2: 여러 액션 분기

// 같은 컴포넌트에서 지원/취소 두 가지 액션
const [applyState, applyAction] = useActionState(applyJob, initialState);
const [cancelState, cancelAction] = useActionState(cancelApplication, initialState);

패턴 3: 상태 머신으로 활용

// Server Action에서 상태 전이 로직 구현
export const respondPartialApproval = async (prevState, formData) => {
  // 1. 현재 상태 조회
  // 2. 전이 가드 체크
  // 3. 조건부 분기
  // 4. 트랜잭션으로 원자적 전이
  // 5. 새 상태 반환
};

패턴 4: 낙관적 업데이트와 결합

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은요?

저는 react-hook-form을 쓰고 있는데 useActionState로 갈아타는 것이 고민이에요..

react-hook-form의 강점

// 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 개별 접근
  • 리렌더링 최적화 - uncontrolled 방식으로 성능 우수
  • 복잡한 폼 구조 - nested objects, arrays 지원

useActionState가 잘하는 것

// 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 };
}

강점:

  • 서버 검증 통합 - 중복 체크 같은 DB 의존 검증이 자연스러움
  • 번들 사이즈 0 - 라이브러리 설치 불필요
  • Progressive Enhancement - JS 없어도 폼 동작
  • 상태 머신 패턴 - 복잡한 비즈니스 로직 처리

핵심 차이: 검증은 어디서?

관점react-hook-formuseActionState
검증 위치클라이언트서버 (+ 클라이언트 옵션)
에러 피드백실시간 (onChange)제출 후 (onSubmit)
DB 의존 검증별도 API 필요Server Action에서 직접
복잡한 폼 UI⭐ 최적화됨직접 관리 필요

그래서 언제 뭘 쓰나요?

                    실시간 피드백 필요?
                         │
              ┌──── Yes ─┴─ No ────┐
              ▼                    ▼
         react-hook-form      서버 검증 필요?
              │                    │
              │           ┌── Yes ─┴─ No ──┐
              │           ▼                ▼
              │      useActionState    useState
              │           │
              │           │
              ▼           ▼
        ┌─────────────────────────────────┐
          둘 다 쓸 수도 있죠!                
          rhf + Server Action으로     
          사용도 가능해요             
        └─────────────────────────────────┘

저는 왜 react-hook-form을 안 썼는지 고민해봤는데요,

이 프로젝트의 폼들을 보면

특징선택
회원가입아이디 중복 체크 필요useActionState ✅
공고 등록크레딧 잔액 체크 필요useActionState ✅
부분 승인 응답서버 시간 기준 타이머useActionState ✅

거의 모든 폼이 서버 상태에 의존하거나 상태 전이가 필요했어요.

  • 아이디 중복? → DB 확인 필요
  • 크레딧 충분? → 서버에서 확인
  • 이미 지원함? → DB 확인 필요
  • 24시간 지났나? → 서버 시간 기준

이런 상황에서 react-hook-form으로
클라이언트 검증 → API 호출 → 에러 처리하는 것보다
Server Action 하나로 끝내는 게 깔끔했습니다.


언제 useActionState를 쓰면 좋을까?

상황추천
단순 폼 제출 (로그인, 검색)✅ 적합
CRUD 작업✅ 적합
복잡한 상태 전이 (결제, 예약)매우 적합
실시간 데이터 (채팅, 알림)❌ WebSocket/SSE 사용
클라이언트만의 상태❌ useState 사용

마치며

useActionState 동작원리를 뜯어보며 실제 프로젝트에 적용했다는 점이 긍정적이었어요.
react-hook-form과의 조합도 충분히 재밌을 것 같습니다!
그리고 무엇보다도 상태 머신이란 개념으로 상태를 강제하는 측면이 프로젝트의 선명도를 높여주는 느낌이었어요.

  1. Server Action과 결합하면 서버 사이드 상태 머신을 구현할 수 있고
  2. isPending 내장으로 로딩 상태 관리가 간편해지며
  3. 트랜잭션과 결합하면 복잡한 비즈니스 로직도 안전하게 처리됩니다

제가 구현한 "묶음 지원 → 부분 승인 → 24시간 응답" 시스템은 22개 파일에서 이 패턴을 활용했어요.
useState 지옥에서 벗어나 상태 전이를 서버에 위임하니까
클라이언트 코드가 놀랍도록 단순해졌습니다.

React 19를 쓰고 계시다면, 복잡한 폼과 상태를 관리할 때
useActionState를 고려해보시는 것, 어떠신가요?!


참고 자료


profile
불편함을 고민하는 프론트엔드 개발자, 박민철입니다.

0개의 댓글