9편에서 'use server' 한 줄로 서버 함수를 폼에서 부르는 법을 봤다. 그런데 진짜 사용자가 쓸 폼은 한 줄로 끝나지 않는다 — 입력 검증·중복 제출 방지·진행 표시·낙관적 업데이트·서버 에러 표시 까지 다 챙겨야 한다.
이번 편은 그 다섯을 한 폼에 묶는다. React 19 의 useActionState·useOptimistic·useFormStatus 세 훅이 이를 거의 자동화한다.
1. useActionState — 폼 결과 상태 관리
Server Action 의 결과(성공/실패·메시지)를 컴포넌트 상태로 받아 화면에 반영한다. 9편에서 잠깐 봤지만 이번엔 검증과 에러까지 묶어서.
// app/posts/new/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const Schema = z.object({
title: z.string().min(2, '제목은 2자 이상').max(100),
body: z.string().min(10, '본문은 10자 이상'),
});
export async function createPost(prev: any, formData: FormData) {
const parsed = Schema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
});
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
await db.posts.create(parsed.data);
revalidatePath('/posts');
return { ok: true, errors: {} };
}
// app/posts/new/post-form.tsx
'use client';
import { useActionState } from 'react';
import { createPost } from './actions';
const initial = { ok: false, errors: {} as Record<string,string[]> };
export default function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initial);
return (
<form action={formAction}>
<input name="title" placeholder="제목" />
{state.errors.title?.[0] && <p className="error">{state.errors.title[0]}</p>}
<textarea name="body" placeholder="본문" />
{state.errors.body?.[0] && <p className="error">{state.errors.body[0]}</p>}
<button disabled={pending}>{pending ? '저장 중…' : '게시'}</button>
{state.ok && <p className="ok">저장 완료!</p>}
</form>
);
}
Zod 가 한 일 두 가지 — 입력 데이터를 타입 안전하게 파싱 + 에러 메시지 한국어로 직접 작성. parsed.error.flatten().fieldErrors 는 { title: ['제목은 2자 이상'] } 같은 형태로 떨어진다.
2. useFormStatus — 자식 버튼이 진행 상태 받기
pending 을 자식 컴포넌트까지 props 로 내려보내기 귀찮다. useFormStatus 가 그걸 컨텍스트로 자동 전달.
// SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ label = '저장' }) {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? '저장 중…' : label}
</button>
);
}
// 폼 안에서
<form action={formAction}>
…
<SubmitButton />
</form>
SubmitButton 이 부모 폼의 진행 상태를 알아서 읽는다. 같은 버튼을 여러 폼에 재사용해도 각자 자기 폼의 상태를 본다. 마법.
주의 — useFormStatus 위치 — 반드시 <form> 안쪽 컴포넌트여야 작동. 폼 자체에선 안 됨. SubmitButton 처럼 폼 자식으로 분리하는 게 표준 패턴.
3. useOptimistic — 즉시 UI 업데이트
"좋아요" 버튼처럼 빠른 반응이 중요한 곳. 서버 응답 기다리지 말고 일단 화면을 먼저 바꾼다.
'use client';
import { useOptimistic } from 'react';
import { likePost } from './actions';
export default function LikeButton({ post }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
post.likes,
(current, increment: number) => current + increment
);
async function handleLike() {
addOptimisticLike(1); // ① UI 먼저 +1
await likePost(post.id); // ② 서버에 실제 반영
}
return (
<button onClick={handleLike}>
❤ {optimisticLikes}
</button>
);
}
버튼을 누르면 카운트가 즉시 +1 으로 바뀐다. 서버 응답이 도착하면 진짜 값으로 교체. 서버가 실패하면 React 가 자동으로 원래 값으로 롤백. 사용자 입장에선 항상 빠르다.
언제 쓰나 — 실패 확률이 낮고, 잠시 잘못된 값을 보여줘도 큰 문제 없는 액션. 좋아요·북마크·간단한 토글에 적합. 결제·삭제·중요 데이터는 절대 낙관적 업데이트 금지 — 실패 시 사용자가 혼란.
4. Progressive Enhancement — JS 꺼져도 동작
Server Actions 의 숨은 장점. <form action={fn}> 은 JS 가 꺼진 브라우저에서도 표준 HTML 폼 제출로 동작한다.
// 이 코드는 JS 없이도 폼 제출 + 페이지 새로고침
<form action={createPost}>
<input name="title" />
<button>게시</button>
</form>
JS 가 켜져 있으면 React 가 가로채서 페이지 새로고침 없이 부드럽게 처리, JS 가 꺼져 있으면 표준 폼 제출로 폴백. 같은 코드로 두 경우 모두 동작. 옛 React 에선 불가능했던 일.
5. 흔한 패턴 5가지
| 패턴 | 도구 |
| 입력 검증 + 에러 표시 | Zod + useActionState |
| 진행 표시 (저장 중…) | useFormStatus 또는 pending |
| 좋아요 즉시 반영 | useOptimistic |
| 저장 후 다른 페이지로 | redirect() in Server Action |
| 저장 후 목록 갱신 | revalidatePath('/posts') |
이 5가지를 한 번씩 조합한 폼이 실전 표준. 옛 React 에선 react-hook-form + axios + react-query + toast 4개 라이브러리 조합이 필요했던 일이 — Next 14/15 에선 표준 훅 3개로 해결된다.
요약 — 10편 좌표
여기까지 정리. 폼 mutation 의 다섯 축 — useActionState(결과·에러), useFormStatus(자식 버튼 진행 표시), useOptimistic(즉시 UI 반영), Zod(서버 검증), revalidatePath/redirect(후처리). 같은 코드가 JS 꺼진 브라우저에서도 표준 폼으로 동작 — Progressive Enhancement. 다음 편에서 로딩과 에러 UI 를 분리하는 loading.tsx·error.tsx.
다음 편 예고 — loading.tsx·error.tsx
특수 파일로 분리하는 로딩·에러 UI. 11편.