Next.js 교재 · 10편 · 폼 실전

폼과 뮤테이션 — useActionState·낙관적 업데이트

9편 Server Actions 위에 검증·진행 표시·UX 최적화를 얹어 실전 폼 한 세트.

폼 입력이 서버로 전송되고 낙관적 업데이트가 즉시 반영되는 컨셉 일러스트

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편.

© 2026 주나이테크(주) @JUNAITECH