폼 처리의 오래된 고통
React에서 폼을 다루는 일은 늘 번거로웠습니다. 간단한 사용자 정보 수정 폼을 생각해봅시다. 다음을 모두 직접 관리해야 합니다.
- 입력 필드의 값
- 제출 버튼의 로딩 상태
- 서버 응답의 에러 메시지
- 제출 성공 시 화면 업데이트
- 낙관적 업데이트 (서버 응답 전에 UI 먼저 업데이트)
- 에러 시 롤백
이 모든 것을 useState로 관리하다 보면 컴포넌트가 비대해집니다.
function UpdateNameForm() {
const [name, setName] = useState('');
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
setIsPending(true);
setError(null);
try {
await updateName(name);
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button disabled={isPending}>저장</button>
{error && <p>{error}</p>}
</form>
);
}상태 변수가 세 개입니다. name, isPending, error. 각각을 set하는 코드가 흩어져 있습니다. 폼이 늘어나면 같은 보일러플레이트가 반복됩니다.
React 19는 이 패턴을 Actions 라는 새로운 개념으로 통합했습니다.
Action이란 무엇인가
React 19의 Action 은 비동기 함수를 상태 업데이트에 자연스럽게 결합하는 패턴입니다. 정확한 정의는 다음과 같습니다.
"비동기 작업을 처리하는 함수로, React가 자동으로 pending 상태, 에러, 폼 제출을 관리할 수 있는 함수"
Action은 단순히 비동기 함수입니다. 특별한 마법은 없습니다. 하지만 React가 이 함수의 실행 주기를 추적할 수 있게 해주는 새로운 Hook들이 함께 도입되었습니다.
useActionState, 액션의 상태를 추적하다
useActionState는 액션을 React의 상태와 결합하는 Hook입니다. 액션의 결과를 상태처럼 사용할 수 있습니다.
function UpdateNameForm() {
const [error, formAction, isPending] = useActionState(
async (previousState, formData) => {
try {
await updateName(formData.get('name'));
return null; // 성공
} catch (err) {
return err.message; // 에러 메시지를 상태로
}
},
null // 초기 상태
);
return (
<form action={formAction}>
<input name="name" />
<button disabled={isPending}>저장</button>
{error && <p>{error}</p>}
</form>
);
}이전 코드와 비교하면 차이가 명확합니다.
useState세 개가useActionState하나로 줄었습니다try/catch와setIsPending호출이 사라졌습니다- 에러 상태는 액션의 반환값으로 자연스럽게 표현됩니다
<form action={formAction}>으로 폼 자체에 액션을 연결합니다
useActionState의 시그니처
const [state, formAction, isPending] = useActionState(
action, // 비동기 함수 (previousState, formData) => newState
initialState, // 초기 상태
permalink // (선택) SSR 시 사용할 URL
);세 가지를 반환합니다.
state, 액션이 마지막으로 반환한 값 (또는 초기값)formAction, 액션을 실행하는 함수. 폼의action속성에 직접 전달하거나, 직접 호출할 수 있습니다isPending, 액션이 진행 중인지 여부
액션 함수의 첫 번째 인자는 이전 상태 입니다. 이전 상태를 보고 새 상태를 만드는 reducer 패턴과 비슷합니다.
useFormStatus, 자식 컴포넌트에서 폼 상태 읽기
useFormStatus는 부모 폼의 제출 상태를 자식 컴포넌트에서 직접 읽을 수 있게 해주는 Hook입니다. props drilling 없이 폼 상태에 접근할 수 있습니다.
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
return (
<button disabled={pending}>
{pending ? '저장 중...' : '저장'}
</button>
);
}
function UpdateNameForm() {
return (
<form action={updateName}>
<input name="name" />
<SubmitButton />
</form>
);
}SubmitButton은 부모 <form>이 제출 중인지를 직접 알 수 있습니다. 부모로부터 isPending을 props로 받지 않아도 됩니다.
동작 원리와 제약
useFormStatus는 가장 가까운 부모 <form> 의 상태를 읽습니다. 핵심 제약은 다음과 같습니다.
- 반드시
<form>안에서 호출되어야 합니다. form 밖에서 호출하면 빈 상태가 반환됩니다 - 반드시 폼의 자식 컴포넌트여야 합니다. 같은 컴포넌트 안에서 form을 렌더링하고 그 컴포넌트에서
useFormStatus를 호출하면 동작하지 않습니다
// ❌ 같은 컴포넌트 안에서 호출, 동작 안 함
function Form() {
const { pending } = useFormStatus(); // 항상 false
return (
<form action={someAction}>
<button disabled={pending}>저장</button>
</form>
);
}
// ✅ 자식 컴포넌트로 분리, 동작함
function SubmitButton() {
const { pending } = useFormStatus(); // 부모 form의 상태
return <button disabled={pending}>저장</button>;
}
function Form() {
return (
<form action={someAction}>
<SubmitButton />
</form>
);
}useOptimistic, 낙관적 업데이트
낙관적 업데이트 (Optimistic Update) 는 서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 패턴입니다. 사용자가 좋아요 버튼을 누르면, 서버 응답을 기다리지 않고 즉시 좋아요 카운트를 증가시켜 보여주는 식입니다.
이전에는 직접 구현해야 했습니다. 임시 상태를 만들고, 서버 응답이 오면 실제 상태로 교체하고, 에러 시 롤백하는 로직을 모두 작성해야 했습니다.
useOptimistic은 이를 단순화합니다.
function MessageList({ messages }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(currentState, newMessage) => [
...currentState,
{ text: newMessage, sending: true }
]
);
async function sendMessage(formData) {
const text = formData.get('message');
addOptimisticMessage(text); // 즉시 UI에 반영
await actuallySendMessage(text); // 실제 전송
}
return (
<>
{optimisticMessages.map((m, i) => (
<div key={i}>
{m.text} {m.sending && <small>전송 중...</small>}
</div>
))}
<form action={sendMessage}>
<input name="message" />
<button>전송</button>
</form>
</>
);
}useOptimistic은 두 가지를 받습니다.
- 현재 상태 , 일반적으로 props로 받은 실제 데이터
- 업데이트 함수 , 현재 상태와 낙관적 값을 받아 새 상태를 반환
addOptimisticMessage를 호출하면 React가 임시로 새 상태를 보여줍니다. 액션이 끝나면 React는 자동으로 실제 상태로 돌아갑니다. 별도의 롤백 로직이 필요 없습니다.
동작의 핵심
useOptimistic은 액션의 생명주기와 결합되어 동작합니다.
- 액션이 시작되면 낙관적 상태가 활성화됨
- 액션 진행 중에는
addOptimisticMessage로 추가한 값이 보임 - 액션이 끝나면 (성공이든 실패든) 실제 상태로 자동 복귀
만약 실제 데이터가 업데이트되었다면 (예: 서버에서 새 메시지 목록을 받아 props로 내려주면), 자연스럽게 그 값으로 전환됩니다. 실패했다면 원래 상태로 돌아가므로 사용자에게 "전송 실패"를 보여줄 수 있습니다.
세 Hook을 함께 사용하기
실무에서는 세 Hook을 함께 사용하는 경우가 많습니다. 댓글 작성 폼을 예로 보겠습니다.
function CommentForm({ comments, onAdd }) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(state, newComment) => [
...state,
{ text: newComment, sending: true }
]
);
const [error, formAction, isPending] = useActionState(
async (prevError, formData) => {
const text = formData.get('comment');
addOptimisticComment(text);
try {
await api.addComment(text);
onAdd();
return null;
} catch (err) {
return err.message;
}
},
null
);
return (
<>
<CommentList comments={optimisticComments} />
<form action={formAction}>
<input name="comment" />
<SubmitButton />
{error && <p className="error">{error}</p>}
</form>
</>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>댓글 작성</button>;
}세 Hook이 각자의 역할을 합니다.
useOptimistic, 즉각적인 UI 반응을 위한 임시 상태useActionState, 액션의 결과 상태와 진행 상태 관리useFormStatus, 자식 컴포넌트에서 폼 상태에 접근
requestFormReset, 폼을 수동으로 리셋하기
React 19는 <form action={...}> 을 사용하면 액션이 성공한 후 uncontrolled 폼을 자동으로 리셋합니다. 대부분의 경우 이 동작이 적절하지만, 가끔 자동 리셋이 일어나기 전에 명시적으로 폼을 리셋해야 하는 경우 가 있습니다.
requestFormReset은 이를 위한 API입니다. form DOM 요소를 받아 즉시 리셋합니다.
import { useRef } from 'react';
import { requestFormReset } from 'react-dom';
function CommentForm() {
const formRef = useRef(null);
async function action(formData) {
await postComment(formData);
if (formRef.current) {
requestFormReset(formRef.current);
}
}
return <form ref={formRef} action={action}>...</form>;
}대표적인 사용 사례는 채팅 입력처럼 빠른 연속 제출이 일어나는 폼 입니다. 사용자가 메시지를 보낸 직후 다음 메시지를 타이핑하기 시작하면, React의 자동 리셋이 사용자의 새 입력을 덮어쓸 수 있습니다. requestFormReset을 명시적으로 호출하면 이 타이밍 이슈를 피할 수 있습니다.
베스트 프랙티스 정리
1. 폼 처리는 Actions로 통합한다. useState 세 개로 흩어져 있던 로직을 useActionState 하나로 묶을 수 있습니다.
2. 액션 함수는 순수하게 작성한다. previousState와 formData를 받아 새 상태를 반환하는 reducer 패턴을 따릅니다. 에러는 throw하지 말고 상태로 반환합니다.
3. 제출 버튼은 별도 컴포넌트로 분리한다. useFormStatus를 사용하려면 자식 컴포넌트여야 합니다. 작은 분리지만 큰 차이를 만듭니다.
4. 낙관적 업데이트는 사용자가 인지할 수 있게 표시한다. "전송 중..." 같은 인디케이터를 함께 보여주면, 실패 시에도 사용자가 혼란스럽지 않습니다.
5. <form action={...}>을 활용한다. onSubmit + preventDefault보다 깔끔하고, useActionState와 자연스럽게 통합됩니다.
6. Server Actions와 결합하면 더 강력해진다. 다음 글에서 다룰 Server Components와 결합하면, 폼 액션을 서버 함수로 직접 연결할 수 있습니다. 이때 Actions 패러다임의 진가가 드러납니다.
다음 단계
다음 글에서는 React 19의 가장 큰 패러다임 전환인 Server Components 와 use() Hook 을 다룹니다. 데이터 패칭의 위치가 클라이언트에서 서버로 옮겨지면서 컴포넌트 모델 자체가 어떻게 바뀌었는지, 그리고 use()가 왜 기존 Hook 규칙의 예외인지를 살펴봅니다.