이번 편의 결과물: BookForm.tsx와 NewBookPage.tsx가 타입 있는 폼으로 전환되어, 입력·제출 핸들러에 타입 오류 없이 폼이 동작합니다. · 다루는 개념: ChangeEvent/FormEvent 타입, 제어 컴포넌트 상태 타입, react-hook-form register의 타입 자동 추론
04편까지 Header.tsx, BookCard.tsx가 타입 있는 props로 전환됐습니다. bookshelf-ts가 이어받은 bookshelf(react_3 완성 시점)의 책 등록 폼은 이미 react-hook-form(RHF)과 zod로 검증하도록 완성되어 있습니다. 이 편은 새 기능을 추가하지 않고, 그 폼 컴포넌트 두 개에 타입만 입힙니다.
이 편에서 만드는 파일
bookshelf-ts/src/
├── schemas/
│ └── bookSchema.ts ~ .js → .ts 전환 (z.infer로 BookFormValues 도출)
├── components/
│ └── BookForm.tsx ~ .jsx → .tsx 전환 (props interface, 메모 글자 수 카운터 추가)
└── pages/
└── NewBookPage.tsx ~ .jsx → .tsx 전환 (서버 에러 매핑 타입)개념 정리
이벤트 타입은 요소마다 다르다
React의 합성 이벤트는 어떤 DOM 요소에서 발생했는지에 따라 제네릭 타입 인자가 달라집니다.
| 이벤트 타입 | 언제 쓰는가 |
|---|---|
ChangeEvent<HTMLInputElement> | 텍스트·숫자 input 값이 바뀔 때 |
ChangeEvent<HTMLTextAreaElement> | textarea 값이 바뀔 때 |
ChangeEvent<HTMLSelectElement> | select 값이 바뀔 때 |
FormEvent<HTMLFormElement> | 폼이 제출될 때(event.preventDefault() 호출) |
요소 타입을 맞춰 넘겨야 event.target.value의 타입이 정확해집니다. HTMLInputElement를 HTMLSelectElement 자리에 쓰면 실제로는 문제없이 컴파일되는 경우도 있지만, 그 요소만 갖는 속성(checked, selectedIndex 등)에 접근할 때 타입이 어긋나기 시작합니다.
zod 스키마 하나에서 폼 타입을 뽑는다
z.infer<typeof bookSchema>는 zod 스키마의 검증 결과 타입을 그대로 TypeScript 타입으로 만듭니다. 필드를 추가·삭제할 때 스키마 한 곳만 고치면 폼 타입도 함께 바뀝니다.
const bookSchema = z.object({ title: z.string().min(1) })
type BookFormValues = z.infer<typeof bookSchema> // { title: string }register의 타입 자동 추론
useForm<BookFormValues>({ resolver: zodResolver(bookSchema) })처럼 useForm에 타입 인자를 주면, register('title')의 필드 이름 인자가 BookFormValues의 키로 제한됩니다. register('titl')처럼 오타를 내면 그 자리에서 컴파일 오류가 나 실행 전에 잡힙니다. formState.errors.title도 같은 타입 정보를 바탕으로 존재 여부가 좁혀집니다.
캐치 변수는 기본적으로 unknown이다
catch (error)의 error는 타입스크립트에서 기본적으로 unknown입니다. error.message처럼 바로 접근할 수 없고, error instanceof Error로 좁히거나 필요한 모양으로 캐스팅한 뒤 접근합니다. 아직 .js로 남은 api/books.js가 던지는 값처럼, 타입 정보가 없는 값을 다룰 때 특히 자주 마주칩니다.
실습
1. bookSchema를 TypeScript로 전환
// src/schemas/bookSchema.ts
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(),
})
export type BookFormValues = z.infer<typeof bookSchema>파일 확장자만 .ts로 바꿨을 뿐, 검증 규칙은 그대로입니다. export type BookFormValues가 이번 편에서 새로 추가한 유일한 줄입니다.
2. BookForm을 tsx로 전환하고 props 타입 지정
// src/components/BookForm.tsx
import { useState, type ChangeEvent } from 'react'
import { useForm, type UseFormSetError } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { bookSchema, type BookFormValues } from '../schemas/bookSchema'
const COVER_COLORS = ['#2563eb', '#16a34a', '#f59e0b', '#db2777', '#7c3aed', '#0891b2']
const MEMO_MAX_LENGTH = 500
export type BookCreatePayload = BookFormValues & { coverColor: string }
export interface BookFormSubmitHelpers {
setError: UseFormSetError<BookFormValues>
}
interface BookFormProps {
defaultValues?: BookFormValues
onSubmitBook: (values: BookCreatePayload, helpers: BookFormSubmitHelpers) => Promise<void>
submitLabel?: string
}
function BookForm({ defaultValues, onSubmitBook, submitLabel = '저장' }: BookFormProps) {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<BookFormValues>({
resolver: zodResolver(bookSchema),
defaultValues: defaultValues ?? { title: '', author: '', pages: 0, status: 'wish', memo: '' },
})
const memoField = register('memo')
const [memoLength, setMemoLength] = useState(defaultValues?.memo?.length ?? 0)
function handleMemoChange(event: ChangeEvent<HTMLTextAreaElement>) {
setMemoLength(event.target.value.length)
memoField.onChange(event)
}
async function onValid(values: BookFormValues) {
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" {...memoField} onChange={handleMemoChange} maxLength={MEMO_MAX_LENGTH} />
<p className="field-hint">
{memoLength} / {MEMO_MAX_LENGTH}자
</p>
{errors.memo && <p className="field-error">{errors.memo.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '저장 중...' : submitLabel}
</button>
</form>
)
}
export default BookFormmemoField는 register('memo')가 반환하는 { name, onChange, onBlur, ref } 객체입니다. textarea에 그대로 펼치는 대신 onChange만 handleMemoChange로 덮어써, 글자 수를 세는 로컬 상태(memoLength)와 RHF의 원래 onChange를 함께 호출합니다. event의 타입이 ChangeEvent<HTMLTextAreaElement>라서 event.target.value가 정확히 문자열로 좁혀집니다.
onSubmitBook은 BookCreatePayload(폼 값 + coverColor)를 받는 함수로 타입을 명시했습니다. 이 타입과 BookFormSubmitHelpers를 export해 두면 NewBookPage.tsx가 같은 타입을 그대로 가져다 씁니다.
3. NewBookPage를 tsx로 전환
// src/pages/NewBookPage.tsx
import { useNavigate } from 'react-router'
import BookForm, { type BookCreatePayload, type BookFormSubmitHelpers } from '../components/BookForm'
import { useCreateBook } from '../hooks/useBookMutations'
import type { BookFormValues } from '../schemas/bookSchema'
interface ServerFieldError {
fieldErrors?: Partial<Record<keyof BookFormValues, string>>
}
function NewBookPage() {
const navigate = useNavigate()
const createBook = useCreateBook()
async function handleSubmitBook(values: BookCreatePayload, { setError }: BookFormSubmitHelpers) {
try {
await createBook.mutateAsync(values)
navigate('/')
} catch (error) {
const fieldErrors = (error as ServerFieldError).fieldErrors ?? {}
const fields = Object.keys(fieldErrors) as (keyof BookFormValues)[]
if (fields.length === 0) {
setError('root', { message: '저장에 실패했습니다. 잠시 후 다시 시도하세요.' })
return
}
fields.forEach((field) => {
const message = fieldErrors[field]
if (message) {
setError(field, { type: 'server', message })
}
})
}
}
return (
<section>
<h2>책 등록</h2>
<BookForm onSubmitBook={handleSubmitBook} submitLabel="등록" />
</section>
)
}
export default NewBookPageuseCreateBook은 아직 .js로 남은 hooks/useBookMutations.js가 내보냅니다. 인자·반환값에 명시적 타입이 없어 createBook.mutateAsync(values) 호출은 지금은 느슨하게 통과합니다. 이 훅에 정식으로 타입이 붙는 시점은 12편(TanStack Query 타이핑)입니다. 지금은 BookForm·NewBookPage 사이의 경계만 엄격하게 지키는 것이 이 편의 목표입니다.
catch (error)의 error는 unknown이라 바로 error.fieldErrors에 접근할 수 없습니다. api/books.js의 createBook이 Object.assign(new Error(...), { fieldErrors }) 형태로 던진다는 것을 알고 있으므로, ServerFieldError 모양으로 캐스팅한 뒤 접근합니다.
4. router.jsx의 import 경로 확인
router.jsx는 아직 자바스크립트 파일이라 이번 편의 대상이 아닙니다. import NewBookPage from './pages/NewBookPage.jsx'처럼 확장자를 명시해 가져오고 있었다면, 파일이 .tsx로 바뀐 지금 이 한 줄만 .tsx로 맞춰 둡니다. 라우터 자체를 정식으로 전환하는 작업은 08편에서 이어집니다.
5. 실행
npm run dev6. 확인
- 책 등록 폼(
/books/new)에서 제목·저자를 비운 채 제출하면 입력창 아래 에러 메시지가 그대로 뜹니다. - 메모 칸에 타이핑하면 입력창 아래
글자 수 / 500자가 실시간으로 갱신됩니다. - VS Code에서
register('titl')처럼 존재하지 않는 필드 이름을 넣어 보면 빨간 밑줄과 함께 오류가 표시됩니다. 확인 후 원래대로 되돌립니다. - db.json에 이미 있는 제목으로 등록을 시도하면 제목 입력창 아래에 서버 에러 메시지가 표시됩니다.
직접 해보기
bookSchema에rating: z.coerce.number().int().min(0).max(5)필드를 추가하고,BookFormValues에 자동으로rating: number가 포함되는지 확인해 보세요.BookForm의defaultValues에도rating: 0을 추가해야 초기 렌더 경고가 나지 않습니다.formState.errors.root가 있을 때 폼 상단에 공통 에러 배너를 렌더링해 보세요.
정답 보기
// src/components/BookForm.tsx (return 안 최상단에 추가)
{errors.root && <p className="form-error-banner">{errors.root.message}</p>}rating 필드를 추가하면 BookFormValues가 스키마로부터 자동으로 다시 계산되므로, BookForm이나 NewBookPage의 타입 선언을 따로 손댈 필요가 없습니다. defaultValues처럼 실제 객체를 만드는 곳에서만 새 필드를 채워주면 됩니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
텍스트 입력창인데 이벤트 타입에서 checked 속성을 찾는 오류 | ChangeEvent의 제네릭을 실제 요소와 다르게(예: 체크박스용 타입) 지정함 | 요소 종류에 맞는 HTMLInputElement·HTMLTextAreaElement·HTMLSelectElement를 정확히 지정한다 |
error.message에서 속성이 없다는 오류 | catch (error)의 error가 unknown인데 바로 속성 접근 | instanceof Error로 좁히거나 알고 있는 모양으로 캐스팅한다 |
register('titl')처럼 오타를 냈는데 오류가 안 남 | useForm에 타입 인자(useForm<BookFormValues>)를 안 줌 | useForm 호출에 반드시 폼 값 타입을 명시한다 |
| 메모 글자 수가 갱신되지 않음 | textarea의 onChange를 덮어쓰면서 memoField.onChange(event) 호출을 빠뜨림 | RHF가 값 추적을 계속하도록 원래 onChange도 함께 호출한다 |