Skip to Content
WebReactReact 실무10. react-hook-form + Zod로 책 등록/수정 폼 재작성

이번 편의 결과물: 책 등록/수정 폼이 zod 스키마로 검증되고, 서버가 요청을 거부하면 해당 필드 아래 에러 메시지가 뜹니다. · 다루는 개념: useForm/register, zod 스키마 검증, 서버 검증 에러를 필드 에러로 매핑

이 편에서 만드는 파일

bookshelf/src/ ├── schemas/ │ └── bookSchema.js + 신규 ├── components/ │ └── BookForm.jsx ~ 전면 재작성 (수동 검증 → RHF + zod) ├── api/ │ └── books.js ~ 수정 (createBook이 중복 제목을 필드 에러로 거부) └── pages/ └── NewBookPage.jsx ~ 수정 (서버 에러를 필드 에러로 매핑)

개념 정리

지금까지 BookFormuseState로 필드마다 값을 들고, handleSubmit 안에서 title.trim() === ''처럼 조건문을 직접 나열해 검증했습니다. 필드가 늘어날수록 이 조건문도 늘어나고, “제목은 몇 자 이상이어야 하는가” 같은 규칙이 코드 여기저기 흩어집니다.

react-hook-form(RHF)은 각 input을 register로 등록해 값·검증·리렌더를 관리하는 라이브러리입니다. zod는 값의 모양을 선언적으로 정의하고 검사하는 스키마 라이브러리입니다. 둘을 합치면 “무엇이 유효한 값인가”는 zod 스키마 하나에, “폼을 어떻게 그릴 것인가”는 컴포넌트에 남습니다.

조각역할
z.object({...})필드별 타입·제약을 선언한 스키마
useForm({ resolver: zodResolver(schema) })제출 시 스키마로 자동 검증
register('title')input을 폼에 등록. value·onChange를 대신 연결
formState.errors필드별 검증 실패 메시지 모음
handleSubmit(onValid)검증 통과 시에만 onValid 호출, 실패 시 errors만 채움

RHF는 대부분의 input을 비제어(uncontrolled) 방식으로 다뤄 타이핑마다 리렌더하지 않습니다. register가 반환하는 { name, onChange, onBlur, ref } 객체를 input에 그대로 펼치면 됩니다.

zod 스키마는 필드 하나에도 여러 규칙을 이어 쓸 수 있습니다.

표현의미
z.string().min(1, 메시지)빈 문자열 금지
z.string().max(100, 메시지)최대 길이 제한
z.number().int().min(0)0 이상 정수
z.enum(['wish', 'reading', 'done'])정해진 값 중 하나만 허용

클라이언트 검증을 통과해도 서버가 거부할 수 있습니다(예: 같은 제목의 책이 이미 있음). 이때는 서버 응답의 에러를 RHF의 setError(필드명, { message })로 옮겨 같은 에러 UI를 재사용합니다.

실습

1. 라이브러리 설치

npm install react-hook-form zod @hookform/resolvers

2. zod 스키마 작성

// src/schemas/bookSchema.js import { z } from 'zod'; export const bookSchema = z.object({ title: z.string().min(1, '제목을 입력하세요').max(100, '제목은 100자 이하로 입력하세요'), author: z.string().min(1, '저자를 입력하세요'), pages: z.coerce.number().int('정수로 입력하세요').min(0, '0 이상이어야 합니다'), status: z.enum(['wish', 'reading', 'done']), memo: z.string().max(500, '메모는 500자 이하로 입력하세요').optional(), });

pages input의 값은 문자열로 들어오므로 z.coerce.number()로 숫자로 변환한 뒤 검사합니다. memo.optional()이라 비워도 통과합니다.

3. BookForm을 RHF + zod로 재작성

// src/components/BookForm.jsx import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { bookSchema } from '../schemas/bookSchema'; const COVER_COLORS = ['#2563eb', '#16a34a', '#f59e0b', '#db2777', '#7c3aed', '#0891b2']; function BookForm({ defaultValues, onSubmitBook, submitLabel = '저장' }) { const { register, handleSubmit, setError, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(bookSchema), defaultValues: defaultValues ?? { title: '', author: '', pages: 0, status: 'wish', memo: '' }, }); async function onValid(values) { const coverColor = COVER_COLORS[Math.floor(Math.random() * COVER_COLORS.length)]; try { await onSubmitBook({ ...values, coverColor }, { setError }); } catch { // onSubmitBook 쪽에서 setError로 이미 처리한다 } } return ( <form className="book-form" onSubmit={handleSubmit(onValid)} noValidate> <label htmlFor="book-title">제목</label> <input id="book-title" type="text" {...register('title')} /> {errors.title && <p className="field-error">{errors.title.message}</p>} <label htmlFor="book-author">저자</label> <input id="book-author" type="text" {...register('author')} /> {errors.author && <p className="field-error">{errors.author.message}</p>} <label htmlFor="book-pages">쪽수</label> <input id="book-pages" type="number" {...register('pages')} /> {errors.pages && <p className="field-error">{errors.pages.message}</p>} <label htmlFor="book-status">상태</label> <select id="book-status" {...register('status')}> <option value="wish">읽고 싶어요</option> <option value="reading">읽는 중</option> <option value="done">다 읽음</option> </select> <label htmlFor="book-memo">메모</label> <textarea id="book-memo" {...register('memo')} /> {errors.memo && <p className="field-error">{errors.memo.message}</p>} <button type="submit" disabled={isSubmitting}> {isSubmitting ? '저장 중...' : submitLabel} </button> </form> ); } export default BookForm;

register('title')이 반환하는 객체를 {...register('title')}로 펼치면 name, onChange, onBlur, ref가 한 번에 연결됩니다. errors.title은 zod가 그 필드에서 실패했을 때만 존재합니다.

4. createBook이 중복 제목을 필드 에러로 거부하도록 수정

json-server는 요청 내용을 검사하지 않고 그대로 저장하므로, 서버가 거부하는 상황이 저절로 생기지 않습니다. 이 편은 createBook이 저장 전에 같은 제목이 있는지 직접 조회해, 있으면 실제 서버 검증처럼 필드 에러를 던지도록 만듭니다.

// src/api/books.js (createBook만 발췌 — 나머지 함수는 05~08편 코드 유지) export async function createBook(book) { const existing = await fetch( `${API_BASE}/books?title=${encodeURIComponent(book.title)}`, ).then((res) => res.json()); if (existing.length > 0) { throw Object.assign(new Error('책 등록에 실패했습니다'), { fieldErrors: { title: '이미 등록된 제목입니다' }, }); } const response = await fetch(`${API_BASE}/books`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(book), }); if (!response.ok) throw new Error('책 등록에 실패했습니다'); return response.json(); }

5. NewBookPage에서 서버 에러 매핑

// src/pages/NewBookPage.jsx import { useNavigate } from 'react-router'; import BookForm from '../components/BookForm'; import { useCreateBook } from '../hooks/useBookMutations.js'; function NewBookPage() { const navigate = useNavigate(); const createBook = useCreateBook(); async function handleSubmitBook(values, { setError }) { try { await createBook.mutateAsync(values); navigate('/books'); } catch (error) { const fieldErrors = error.fieldErrors ?? {}; const fields = Object.keys(fieldErrors); if (fields.length === 0) { setError('root', { message: '저장에 실패했습니다. 잠시 후 다시 시도하세요.' }); return; } fields.forEach((field) => { setError(field, { type: 'server', message: fieldErrors[field] }); }); } } return ( <section> <h2>책 등록</h2> <BookForm onSubmitBook={handleSubmitBook} submitLabel="등록" /> </section> ); } export default NewBookPage;

error.fieldErrors는 방금 고친 createBook이 중복 제목일 때 던지는 값입니다. 필드별 에러가 없으면 root라는 특수 이름으로 폼 전체 에러를 표시합니다.

6. 실행

npm run dev

7. 확인

  • 제목이나 저자를 비운 채 제출하면 브라우저 새로고침 없이 해당 입력창 아래 빨간 글씨 에러가 뜹니다.
  • 쪽수에 -5처럼 음수를 입력하면 “0 이상이어야 합니다” 에러가 뜹니다.
  • db.json에 이미 있는 제목(예: “클린 코드”)으로 등록을 시도하면 제목 입력창 아래에 “이미 등록된 제목입니다”가 표시됩니다.
  • 정상 값으로 제출하면 /books로 이동하고 목록에 새 책이 보입니다.

직접 해보기

  1. bookSchemarating: z.coerce.number().int().min(0).max(5) 필드를 추가하고, BookForm에 별점 입력을 위한 <input type="number" min="0" max="5">를 추가해 보세요.
  2. formState.errors.root가 있을 때 폼 상단에 공통 에러 배너를 렌더링해 보세요.

정답 보기

// src/components/BookForm.jsx (배너 추가 — return 안 최상단) {errors.root && <p className="form-error-banner">{errors.root.message}</p>}

rating 필드는 bookSchema에 한 줄 추가하고, defaultValues의 기본값에도 rating: 0을 넣어야 초기 렌더에서 undefined 경고가 나지 않습니다.

자주 하는 실수

증상원인고치는 법
제출해도 검증이 전혀 안 됨useFormresolver: zodResolver(schema)를 안 넘김resolver 옵션 확인
숫자 필드인데 항상 문자열 검증 에러가 남HTML input 값은 항상 문자열이라 z.number()가 즉시 실패z.coerce.number()로 변환 후 검사
서버 에러가 필드 아래 안 뜨고 콘솔에만 찍힘setError를 호출하지 않고 catch에서 로그만 남김응답의 필드별 에러를 순회하며 setError(field, {...}) 호출
제출 버튼을 연타하면 요청이 여러 번 나감isSubmitting 상태를 버튼에 연결하지 않음disabled={isSubmitting} 적용

확인 문제

문제 14지선다
react-hook-form에서 register('title')이 하는 일은?
문제 24지선다
쪽수처럼 숫자를 입력받는 필드에 z.coerce.number()를 쓰는 이유는?
문제 34지선다
서버가 거부한 필드 에러를 화면에 표시하는 올바른 방법은?
문제 44지선다
isSubmitting을 제출 버튼의 disabled에 연결하는 이유는?

참고 자료

Last updated on