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

크리스가 닉네임 변경 페이지를 만들고 있다.
아주 간단한 기능이다. 입력창에 새 닉네임을 쓰고 '변경' 버튼을 누르면 서버로 전송하면 된다.
그런데 이 간단한 기능을 구현하기 위해 크리스는 무려 3개의 상태(useState)를 선언하고, try-catch-finally 범벅이 된 핸들러 함수를 작성하고 있다.
// ❌ 크리스의 고단한 일상 (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에서는 폼 제출과 비동기 상태 관리를 자동화해 주는 useActionState와 useFormStatus가 도입되었다. 이제 useState와 try-catch의 지옥에서 탈출할 시간이다.
1. useActionState: 액션의 결과를 알아서 관리한다
useActionState는 비동기 함수(액션)를 감싸서, 그 함수의 실행 결과(state)와 진행 상태(pending)를 자동으로 관리해 주는 훅이다.
(참고: 개발 초기 단계인 Canary 버전에서는 useFormState라고 불렸으나, 폼 이외의 일반적인 액션에도 쓸 수 있다는 의미에서 useActionState로 이름이 변경되었다.)
사용법
const [state, formAction, isPending] = useActionState(actionFn, initialState);리팩토링: useState 삭제하기
크리스의 코드를 바꿔보자. 먼저 비동기 로직을 순수 함수로 분리한다.
// 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 하나만 쓰면 된다.
// 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로 내려줘야 한다.
// ❌ Props Drilling 발생
function NicknameForm() {
const [state, formAction, isPending] = useActionState(...);
return (
<form action={formAction}>
{/* 자식 버튼에게 로딩 상태를 일일이 넘겨줘야 함 */}
<SubmitButton isLoading={isPending} />
</form>
);
}이 귀찮음을 해결해 주는 것이 useFormStatus다. 이 훅은 마치 Context처럼 동작한다.
<form> 내부의 어떤 자식 컴포넌트에서든 호출하기만 하면, "지금 내가 속한 부모 폼이 제출 중인지" 상태를 알 수 있다.
SubmitButton 컴포넌트
// SubmitButton.tsx
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
// 부모 폼의 상태를 구독한다. (Props 필요 없음!)
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '처리 중...' : '전송'}
</button>
);
}깔끔해진 부모 컴포넌트
// 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가 비활성화된 환경에서도 폼이 동작하게 만들 수 있다.
useActionState는 이 두 세계(MPA와 SPA)를 연결하는 다리 역할을 한다. 물론 순수 클라이언트 리액트(Vite 등)에서도 로직 단순화의 이점은 여전히 강력하다.
핵심 정리
이제 폼 상태 관리도 자동화되었다. 하지만 아직 부족하다.
사용자가 댓글을 달았을 때, 서버 응답을 기다렸다가 목록을 갱신하면 0.5초의 딜레이가 생긴다.
우리는 Part 3에서 TanStack Query로 낙관적 업데이트(Optimistic Update)를 구현해 봤지만, 이제는 외부 라이브러리 없이 리액트 내장 훅만으로 이 마법을 부릴 수 있다.
"useOptimistic: useState 없이 구현하는 낙관적 업데이트" 편에서 계속된다.