React|React Hook 톱아보기

useActionState & useFormStatus: 폼(Form) 관리의 혁명

0
useActionState & useFormStatus: 폼(Form) 관리의 혁명

크리스가 닉네임 변경 페이지를 만들고 있다.

아주 간단한 기능이다. 입력창에 새 닉네임을 쓰고 '변경' 버튼을 누르면 서버로 전송하면 된다.

그런데 이 간단한 기능을 구현하기 위해 크리스는 무려 3개의 상태(useState)를 선언하고, try-catch-finally 범벅이 된 핸들러 함수를 작성하고 있다.

typescript
// ❌ 크리스의 고단한 일상 (Boilerplate)
function NicknameForm() {
  const [nickname, setNickname] = useState('');
  const [isLoading, setIsLoading] = useState(false); // 로딩 상태
  const [error, setError] = useState<string | null>(null); // 에러 상태
  const [success, setSuccess] = useState(false); // 성공 상태

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsLoading(true);
    setError(null);
    setSuccess(false);

    try {
      await updateNickname(nickname);
      setSuccess(true);
    } catch (err) {
      setError(err.message);
    } finally {
      setIsLoading(false); // 로딩 끝
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={nickname} onChange={e => setNickname(e.target.value)} />
      <button disabled={isLoading}>
        {isLoading ? '변경 중...' : '변경'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}

"고작 폼 하나 만드는데 코드가 이게 뭐야? 로딩 처리하고 에러 잡는 로직은 매번 똑같은데..."

리액트 팀도 이 고통을 알고 있었다. React 19에서는 폼 제출과 비동기 상태 관리를 자동화해 주는 useActionStateuseFormStatus가 도입되었다. 이제 useStatetry-catch의 지옥에서 탈출할 시간이다.

1. useActionState: 액션의 결과를 알아서 관리한다

useActionState는 비동기 함수(액션)를 감싸서, 그 함수의 실행 결과(state)와 진행 상태(pending)를 자동으로 관리해 주는 훅이다.

(참고: 개발 초기 단계인 Canary 버전에서는 useFormState라고 불렸으나, 폼 이외의 일반적인 액션에도 쓸 수 있다는 의미에서 useActionState로 이름이 변경되었다.)

사용법

typescript
const [state, formAction, isPending] = useActionState(actionFn, initialState);
  • state: 액션 함수가 반환한 최신 값 (성공 메시지나 에러 객체 등).
  • formAction: <form action={...}>이나 버튼에 연결할 함수.
  • isPending: 액션이 실행 중인지 알려주는 true/false 값. (자동 로딩 처리!)
  • 리팩토링: useState 삭제하기

    크리스의 코드를 바꿔보자. 먼저 비동기 로직을 순수 함수로 분리한다.

    typescript
    // actions.ts
    // 이전 상태(prevState)와 폼 데이터(formData)를 받는다.
    export async function updateNicknameAction(prevState: any, formData: FormData) {
      const nickname = formData.get('nickname') as string;
      
      try {
        await updateNickname(nickname); // API 호출
        return { success: true, message: "변경 완료!" };
      } catch (err) {
        return { success: false, message: "에러 발생: " + err.message };
      }
    }

    이제 컴포넌트에서는 useState를 싹 지우고 useActionState 하나만 쓰면 된다.

    typescript
    // NicknameForm.tsx
    import { useActionState } from 'react';
    import { updateNicknameAction } from './actions';
    
    function NicknameForm() {
      // ✅ 로딩, 에러, 결과 상태를 한 번에 관리
      const [state, formAction, isPending] = useActionState(updateNicknameAction, null);
    
      return (
        <form action={formAction}>
          <input name="nickname" required />
          
          {/* isPending이 자동으로 true/false로 바뀐다 */}
          <button disabled={isPending}>
            {isPending ? '변경 중...' : '변경'}
          </button>
    
          {state?.message && <p>{state.message}</p>}
        </form>
      );
    }

    try-catch도, setIsLoading(true)도 사라졌다. 리액트가 액션의 시작과 끝을 감지해서 isPending을 토글 해주고, 반환된 값을 state에 넣어준다. 코드가 절반으로 줄었다.

    2. useFormStatus: Props 내리꽂기(Drilling) 멈춰!

    폼을 만들다 보면 '제출 버튼'을 별도의 컴포넌트(SubmitButton)로 분리하고 싶을 때가 있다. 디자인 시스템을 적용하거나 재사용하기 위해서다.

    그런데 버튼을 분리하면 로딩 상태(isPending)를 알 수 없게 된다. 결국 부모 폼에서 props로 내려줘야 한다.

    typescript
    // ❌ Props Drilling 발생
    function NicknameForm() {
      const [state, formAction, isPending] = useActionState(...);
      
      return (
        <form action={formAction}>
          {/* 자식 버튼에게 로딩 상태를 일일이 넘겨줘야 함 */}
          <SubmitButton isLoading={isPending} />
        </form>
      );
    }

    이 귀찮음을 해결해 주는 것이 useFormStatus다. 이 훅은 마치 Context처럼 동작한다.

    <form> 내부의 어떤 자식 컴포넌트에서든 호출하기만 하면, "지금 내가 속한 부모 폼이 제출 중인지" 상태를 알 수 있다.

    SubmitButton 컴포넌트

    typescript
    // SubmitButton.tsx
    import { useFormStatus } from 'react-dom';
    
    export function SubmitButton() {
      // 부모 폼의 상태를 구독한다. (Props 필요 없음!)
      const { pending } = useFormStatus();
    
      return (
        <button type="submit" disabled={pending}>
          {pending ? '처리 중...' : '전송'}
        </button>
      );
    }

    깔끔해진 부모 컴포넌트

    typescript
    // NicknameForm.tsx
    function NicknameForm() {
      const [state, formAction] = useActionState(updateNicknameAction, null);
    
      return (
        <form action={formAction}>
          <input name="nickname" />
          {/* Props 없이 그냥 배치하면 끝 */}
          <SubmitButton />
          <p>{state?.message}</p>
        </form>
      );
    }

    주의할 점: useFormStatus는 반드시 <form>내부(자식 컴포넌트)에서 렌더링 되어야 동작한다. 폼을 정의하는 컴포넌트 자기 자신(NicknameForm) 안에서는 쓸 수 없다.

    3. 왜 혁명인가? (Progressive Enhancement)

    이 훅들이 혁명적인 이유는 단순히 코드가 줄어서가 아니다. "점진적 향상(Progressive Enhancement)"을 지원하기 때문이다.

    Next.js 같은 프레임워크와 함께 사용하면, 자바스크립트가 브라우저에 로드되기 전이나, JS가 비활성화된 환경에서도 폼이 동작하게 만들 수 있다.

  • 사용자가 버튼 클릭 -> 브라우저 기본 동작으로 폼 제출 (POST 요청).
  • 서버에서 액션 처리 후 결과와 함께 HTML 응답.
  • JS가 로드되면 -> 리액트가 가로채서(Hydration) 부드러운 SPA 경험 제공.
  • useActionState는 이 두 세계(MPA와 SPA)를 연결하는 다리 역할을 한다. 물론 순수 클라이언트 리액트(Vite 등)에서도 로직 단순화의 이점은 여전히 강력하다.

    핵심 정리

  • useActionState: 비동기 액션 함수의 결과(state)와 로딩 상태(isPending)를 자동으로 관리해 준다. useStatetry-catch 보일러플레이트를 제거하는 강력한 도구다.
  • useFormStatus: 폼 내부의 자식 컴포넌트가 부모 폼의 로딩 상태(pending)에 접근할 수 있게 해 준다. Props Drilling을 없애고 컴포넌트의 결합도를 낮춘다.
  • 패러다임의 변화: 이벤트 핸들러(onSubmit) 중심의 사고방식에서, 데이터와 액션(action) 중심의 선언적 코드로 폼 관리가 진화했다.
  • 이제 폼 상태 관리도 자동화되었다. 하지만 아직 부족하다.

    사용자가 댓글을 달았을 때, 서버 응답을 기다렸다가 목록을 갱신하면 0.5초의 딜레이가 생긴다.

    우리는 Part 3에서 TanStack Query로 낙관적 업데이트(Optimistic Update)를 구현해 봤지만, 이제는 외부 라이브러리 없이 리액트 내장 훅만으로 이 마법을 부릴 수 있다.

    "useOptimistic: useState 없이 구현하는 낙관적 업데이트" 편에서 계속된다.


    🔗 참고 링크

  • React 19 Beta - Actions
  • React Docs - useActionState
  • React Docs - useFormStatus
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...