Skip to content

폼 작성 가이드 (react-hook-form + zod)

dictionary-app의 폼은 react-hook-form이 입력 상태를, zod 스키마가 유효성 검사를 맡는다. 두 라이브러리는 @hookform/resolvers/zod의 zodResolver로 연결한다. 로그인 화면, 등록/수정 모달, 검색 조건 입력이 모두 같은 방식으로 작성되어 있다.

flowchart LR
  schema["zod 스키마<br/>(검증 규칙 + 에러 메시지)"] -- "zodResolver" --> form["useForm"]
  form -- "register / control" --> field["공통 폼 필드<br/>(FormField 등)"]
  field -- "입력" --> form
  form -- "handleSubmit" --> submit{"검증 통과?"}
  submit -- "예" --> onSubmit["onSubmit(values)<br/>→ 요청 DTO로 변환 후 API 호출"]
  submit -- "아니오" --> errors["formState.errors<br/>→ 필드 아래 에러 메시지"]
  1. 스키마는 폼을 쓰는 컴포넌트(또는 페이지 훅) 파일 안에 상수로 선언하고, 이름은 xxxFormSchema(검색 조건은 xxxFilterSchema)로 짓는다. 에러 메시지는 검증 메소드의 두 번째 인자로 한국어로 적는다.
    pages/login/Login.tsx
    import {z} from 'zod'
    type AuthLoginRequestDto = components['schemas']['AuthLoginRequestDto']
    const loginSchema = z.object({
    email: z.string().min(1, '이메일을 입력하세요').email('이메일 형식이 아닙니다'),
    password: z.string().min(1, '비밀번호를 입력하세요'),
    }) satisfies z.ZodType<AuthLoginRequestDto>
  2. 스키마 끝에는 항상 satisfies z.ZodType<폼 값 타입>을 붙인다 — 스키마와 폼 값 타입의 필드가 어긋나면 컴파일 단계에서 바로 드러난다. 폼 값 타입은 스키마에서 z.infer로 뽑지 않고 직접 선언한 타입을 쓴다.
    • 백엔드 요청 DTO를 그대로 폼 값으로 쓸 수 있으면 components['schemas']['...']를 그대로 쓴다(로그인).
    • 입력 단계와 요청 DTO의 모양이 다르면 페이지의 models/에 XxxFormValues 타입을 따로 둔다(도메인 등록/수정).
  3. 필수 항목은 .min(1, '...')로 빈 문자열을 거부한다. 선택 항목은 z.string()만 둔다.
  4. 규칙이 내장 메소드로 표현되지 않으면 .refine(검사 함수, '메시지')를 쓰고, 검사 함수는 @/utils의 공용 함수를 재사용한다.
    pages/word/domain/components/DomainFormModal.tsx
    const domainFormSchema = z.object({
    name: z.string().min(1, '이름을 입력하세요'),
    description: z.string().min(1, '상세설명을 입력하세요'),
    suffix: z.string().min(1, '접미사를 입력하세요'),
    dataTypeCd: z.string().min(1, '데이터타입을 선택하세요'),
    useYn: z.string().min(1, '사용여부를 선택하세요'),
    regexPatternNm: z.string(),
    decimal: z.string().refine(isIntegerString, '숫자만 입력하세요'),
    max: z.string().refine(isIntegerString, '숫자만 입력하세요'),
    min: z.string().refine(isIntegerString, '숫자만 입력하세요'),
    unitCd: z.string(),
    }) satisfies z.ZodType<DomainFormValues>
  1. useForm에 폼 값 타입을 제네릭으로 넘기고, resolver에 zodResolver(스키마)를 연결한다. 초기값이 있는 폼은 defaultValues에 초기값 상수(XXX_FORM_INITIAL)를 넘긴다.
    pages/word/domain/components/DomainFormModal.tsx
    import {useForm} from 'react-hook-form'
    import {zodResolver} from '@hookform/resolvers/zod'
    /** 폼 입력값 초기값. useYn은 기본값을 '사용'(Y)으로 둔다. */
    const DOMAIN_FORM_INITIAL: DomainFormValues = {
    name: '',
    description: '',
    suffix: '',
    dataTypeCd: '',
    useYn: 'Y',
    regexPatternNm: '',
    decimal: '',
    max: '',
    min: '',
    unitCd: '',
    }
    const {
    register,
    control,
    handleSubmit,
    reset,
    formState: {errors},
    } = useForm<DomainFormValues>({
    resolver: zodResolver(domainFormSchema),
    defaultValues: DOMAIN_FORM_INITIAL,
    })
  2. 초기값의 모든 필드는 undefined가 아니라 빈 문자열('')로 채운다 — 입력창이 처음부터 제어되는 값을 갖게 하고, 스키마의 z.string() 검사와도 맞추기 위해서다.
  3. 필요한 값만 구조 분해해서 쓴다. 제출 버튼 상태가 필요하면 formState에서 isSubmitting/isValid를 함께 꺼낸다(로그인 화면).
    pages/login/Login.tsx
    const {
    register,
    handleSubmit,
    formState: {errors, isSubmitting, isValid},
    } = useForm<AuthLoginRequestDto>({resolver: zodResolver(loginSchema)})

입력 컴포넌트를 화면마다 직접 register와 묶지 않고, 라벨·필수 표시(*)·에러 메시지까지 처리하는 공통 폼 필드를 쓴다. 세 컴포넌트 모두 폼 값 타입을 제네릭으로 받아 name이 그 타입의 키로 제한된다.

컴포넌트 입력 요소 폼 연결 방식
FormField 텍스트 입력(Input) register
FormSelectField 네이티브 <select>(NativeSelect) register
FormFilterableSelectField 검색 가능한 콤보박스(FilterableSelect) control (useController)
  1. register를 그대로 스프레드할 수 있는 입력(텍스트, 네이티브 select)은 register와 errors.필드명을 넘긴다. 필수 항목은 required를 붙여 라벨에 *를 표시한다 — 표시만 할 뿐, 실제 필수 검사는 스키마의 .min(1, ...)이 한다.
    <FormField<DomainFormValues>
    name="name"
    label="이름"
    placeholder="예) 코드"
    register={register}
    error={errors.name}
    required
    />
    <FormSelectField<DomainFormValues>
    name="dataTypeCd"
    label="데이터타입"
    placeholder="선택하세요"
    options={DATA_TYPE_OPTIONS}
    register={register}
    error={errors.dataTypeCd}
    required
    />
  2. 값과 변경을 value/handleChange로 직접 주고받는 컨트롤드 컴포넌트는 register로 연결할 수 없다. 이런 입력은 control을 넘기고, 공통 필드 안에서 useController로 field.value/field.onChange를 이어준다.
    components/molecules/form-filterable-select-field/FormFilterableSelectField.tsx
    const {
    field: {value, onChange},
    } = useController({name, control})
    return (
    <FieldRoot invalid={!!error}>
    <FormLabel label={label} required={required} requiredMarkPosition={requiredMarkPosition} />
    <FilterableSelect
    options={options}
    value={value as string | undefined}
    placeholder={placeholder}
    handleChange={onChange}
    />
    {error && <FieldErrorText>{error.message}</FieldErrorText>}
    </FieldRoot>
    )
    pages/word/domain/components/DomainFormModal.tsx
    <FormFilterableSelectField<DomainFormValues>
    name="regexPatternNm"
    label="정규식 패턴"
    options={domainPatternOptions}
    control={control}
    error={errors.regexPatternNm}
    />
  3. 새로운 입력 종류가 필요하면 화면에서 직접 Controller를 쓰지 말고, 위 세 컴포넌트와 같은 형태 (FieldRoot + FormLabel + 입력 + FieldErrorText)로 공통 폼 필드를 components/molecules에 추가한다.
  1. 제출 핸들러는 handleSubmit으로 감싼다. 검증에 실패하면 콜백이 호출되지 않고 errors만 채워지므로, 콜백 안에서는 검증을 통과한 값만 다룬다.
    pages/login/Login.tsx
    const handleLogin = handleSubmit(async (values) => {
    setErrorMessage(null)
    try {
    const sessionUser = await login(values)
    setSessionUser(sessionUser)
    // ...
    navigate('/', {replace: true})
    } catch (error) {
    setErrorMessage(getErrorMessage(error))
    }
    })
    <form onSubmit={handleLogin}>
    {/* ... */}
    <StyledSubmitButton type="submit" loading={isSubmitting} disabled={!isValid || isGuestLoggingIn}>
    로그인
    </StyledSubmitButton>
    </form>
  2. 폼 값과 요청 DTO의 모양이 다르면 콜백 첫 줄에서 변환 함수(toXxxRequestPayload)로 바꾼 뒤 요청한다. 선택 항목의 빈 문자열은 undefined로, 숫자 항목은 Number(...)로 바꾼다.
    pages/word/domain/components/DomainFormModal.tsx
    const toDomainRequestPayload = (values: DomainFormValues): DomainCreateRequestDto => {
    return {
    name: values.name,
    // ...
    regexPatternNm: isNotBlank(values.regexPatternNm) ? values.regexPatternNm : undefined,
    decimal: isNotBlank(values.decimal) ? Number(values.decimal) : undefined,
    max: isNotBlank(values.max) ? Number(values.max) : undefined,
    min: isNotBlank(values.min) ? Number(values.min) : undefined,
    unitCd: isNotBlank(values.unitCd) ? values.unitCd : undefined,
    }
    }
  3. 모달의 확인 버튼에 연결할 때는 handleSubmit으로 감싼 함수를 Modal의 handleConfirm에 그대로 넘긴다. 성공했을 때만 모달을 닫고, 실패하면 모달을 열어둔 채 사용자가 입력을 고쳐 다시 시도하게 한다. 자세한 예시는 모달 사용 예제 참고.
  1. 하나의 폼으로 등록과 수정을 함께 처리하면, 모달이 열릴 때마다 reset으로 폼을 다시 채운다 — 수정 모드면 응답 DTO를 폼 값으로 바꾼 값으로, 등록 모드면 초기값 상수로 채운다.
    pages/word/domain/components/DomainFormModal.tsx
    useEffect(() => {
    if (open) {
    reset(domain ? toDomainFormValues(domain) : DOMAIN_FORM_INITIAL)
    }
    }, [open, domain, reset])
  2. 응답 DTO를 폼 값으로 바꾸는 함수(toXxxFormValues)는 값이 없는 필드를 빈 문자열로, 숫자 필드를 String(...)으로 바꾼다 — 제출할 때의 변환(toXxxRequestPayload)과 정확히 반대 방향이다.
    pages/word/domain/components/DomainFormModal.tsx
    const toDomainFormValues = (domain: DomainResponseDto): DomainFormValues => {
    return {
    name: domain.name ?? '',
    // ...
    decimal: isNotNull(domain.decimal) ? String(domain.decimal) : '',
    max: isNotNull(domain.max) ? String(domain.max) : '',
    min: isNotNull(domain.min) ? String(domain.min) : '',
    unitCd: domain.unitCd ?? '',
    }
    }
  1. 목록 화면의 검색 조건(draft)도 useState 대신 useForm으로 관리한다. 검색 조건은 모두 선택값이라 값을 거부하지 않고, 문자열 타입만 보장하는 최소 스키마를 둔다.
    pages/word/domain/useDomain.ts
    const domainFilterSchema = z.object({
    name: z.string(),
    description: z.string(),
    suffix: z.string(),
    dataTypeCd: z.string(),
    useYn: z.string(),
    fromDt: z.string(),
    toDt: z.string(),
    }) satisfies z.ZodType<DomainFilter>
  2. SearchArea는 상태를 갖지 않는 컴포넌트라 register를 넘기지 않는다. 페이지 훅이 watch()로 현재 값을 읽어 value로 넘기고, 변경은 setValue로 반영한다.
    pages/word/domain/useDomain.ts
    const {watch, setValue, handleSubmit, reset: resetFilterValues} = useForm<DomainFilter>({
    resolver: zodResolver(domainFilterSchema),
    defaultValues: DOMAIN_FILTER_INITIAL,
    })
    const filter = watch()
    const handleFilterChange =
    (key: keyof DomainFilter) => (value: string | number | string[] | undefined) => {
    setValue(key, String(value ?? ''))
    }
    const handleSearch = handleSubmit((values) => {
    search(values)
    })
    const handleReset = () => {
    resetFilterValues(DOMAIN_FILTER_INITIAL)
    reset()
    }
  3. 한 번의 입력이 여러 필드를 바꾸는 경우(등록일 범위 [fromDt, toDt])는 별도 핸들러에서 setValue를 필드마다 호출한다.