블로그 목록
프레임워크Next.js

React 19 Server Actions 정리 — useActionState로 폼 처리가 어떻게 달라졌나

React 18에서 Server Components가 나오고 나서 폼 처리는 항상 애매했다. useState로 로딩 상태 관리하고, fetch로 API 호출하고, 에러 핸들링 따로 붙이는 식이었는데, 코드량 대비 하는 일이 별로 없는 느낌이었다.

React 19에서 Actions 개념이 정식으로 들어오면서 이 흐름이 좀 바뀌었다. useActionState라는 훅이 새로 생겼고, 폼 제출 → 서버 처리 → 상태 업데이트 흐름을 훨씬 적은 코드로 다룰 수 있게 됐다.

useActionState 기본 구조

useActionState는 서버 액션 함수와 초기 상태를 받는다. 반환값은 현재 상태, 액션 함수, 그리고 pending 여부다.

// app/actions.ts
'use server'

type FormState = { error?: string; success?: boolean } | null;

export async function submitForm(prevState: FormState, formData: FormData): Promise<FormState> {
  const email = formData.get('email') as string;

  if (!email.includes('@')) {
    return { error: '유효한 이메일을 입력해주세요.' };
  }

  await saveToDb(email);
  return { success: true };
}
// components/SignupForm.tsx
'use client'

import { useActionState } from 'react';
import { submitForm } from '@/app/actions';

export function SignupForm() {
  const [state, action, isPending] = useActionState(submitForm, null);

  return (
    <form action={action}>
      <input name="email" type="email" />
      {state?.error && <p className="text-red-500">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? '처리 중...' : '가입하기'}
      </button>
    </form>
  );
}

이전에는 useStateisLoading, error, data를 따로 관리하던 것이 useActionState 하나로 압축된다. isPending은 서버 액션이 실행 중인 동안 자동으로 true가 된다. API 라우트를 별도로 만들 필요도 없다.

useFormStatus로 중첩 컴포넌트에서 상태 접근

버튼을 별도 컴포넌트로 분리하고 싶을 때 useFormStatus가 유용하다.

// components/SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom';

export function SubmitButton({ label }: { label: string }) {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? '처리 중...' : label}
    </button>
  );
}

useFormStatus는 가장 가까운 부모 <form>의 상태를 읽는다. 폼 컴포넌트 바깥에서 호출하면 항상 pending: false가 나온다. 처음에 이걸 놓치고 왜 로딩이 안 되는지 한참 봤다.

useOptimistic으로 즉각적인 UI 반응

useOptimistic을 같이 쓰면 서버 응답 전에 UI를 미리 업데이트할 수 있다.

const [optimisticItems, addOptimistic] = useOptimistic(
  items,
  (state: Item[], newItem: Item) => [...state, newItem]
);

async function handleAdd(formData: FormData) {
  const newItem = { id: crypto.randomUUID(), name: formData.get('name') as string };
  addOptimistic(newItem);   // 즉시 반영
  await createItem(newItem); // 서버 액션
}

좋아요 버튼이나 리스트 추가처럼 반응이 즉각적으로 느껴져야 하는 인터랙션에 쓰면 체감 속도가 달라진다. 서버 액션이 실패하면 React가 자동으로 이전 상태로 롤백해준다.

기존 방식과 비교

| 방식 | API 라우트 필요 | 로딩 상태 | 에러 처리 | |------|----------------|-----------|-----------| | useState + fetch | 필요 | 수동 | 수동 | | React Query | 필요 | 자동 | 자동 | | useActionState | 불필요 | 자동 | 액션 반환값 |

모든 케이스에 Server Actions가 맞는 건 아니다. 복잡한 캐시 관리나 리패칭 로직이 필요한 경우엔 React Query가 나을 수 있다. 단순한 폼 제출이나 mutation 위주라면 별도 라이브러리 없이도 충분하다.


실제로 써보니 보일러플레이트가 많이 줄었다. 특히 'use server' 하나로 서버 함수를 정의하고 폼에 직접 넘길 수 있는 게 편하다. 다만 useActionState는 Client Component에서만 쓸 수 있고, useFormStatus는 반드시 <form> 안에서 호출해야 한다는 점은 기억해둘 만하다.