폼 작성 가이드 (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/>→ 필드 아래 에러 메시지"]
스키마 정의
Section titled “스키마 정의”- 스키마는 폼을 쓰는 컴포넌트(또는 페이지 훅) 파일 안에 상수로 선언하고, 이름은
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> - 스키마 끝에는 항상
satisfies z.ZodType<폼 값 타입>을 붙인다 — 스키마와 폼 값 타입의 필드가 어긋나면 컴파일 단계에서 바로 드러난다. 폼 값 타입은 스키마에서z.infer로 뽑지 않고 직접 선언한 타입을 쓴다.- 백엔드 요청 DTO를 그대로 폼 값으로 쓸 수 있으면
components['schemas']['...']를 그대로 쓴다(로그인). - 입력 단계와 요청 DTO의 모양이 다르면 페이지의
models/에XxxFormValues타입을 따로 둔다(도메인 등록/수정).
- 백엔드 요청 DTO를 그대로 폼 값으로 쓸 수 있으면
- 필수 항목은
.min(1, '...')로 빈 문자열을 거부한다. 선택 항목은z.string()만 둔다. - 규칙이 내장 메소드로 표현되지 않으면
.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>
useForm 연결
Section titled “useForm 연결”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,})- 초기값의 모든 필드는
undefined가 아니라 빈 문자열('')로 채운다 — 입력창이 처음부터 제어되는 값을 갖게 하고, 스키마의z.string()검사와도 맞추기 위해서다. - 필요한 값만 구조 분해해서 쓴다. 제출 버튼 상태가 필요하면
formState에서isSubmitting/isValid를 함께 꺼낸다(로그인 화면).pages/login/Login.tsx const {register,handleSubmit,formState: {errors, isSubmitting, isValid},} = useForm<AuthLoginRequestDto>({resolver: zodResolver(loginSchema)})
공통 폼 필드 (@/components)
Section titled “공통 폼 필드 (@/components)”입력 컴포넌트를 화면마다 직접 register와 묶지 않고, 라벨·필수 표시(*)·에러 메시지까지 처리하는
공통 폼 필드를 쓴다. 세 컴포넌트 모두 폼 값 타입을 제네릭으로 받아 name이 그 타입의 키로 제한된다.
| 컴포넌트 | 입력 요소 | 폼 연결 방식 |
|---|---|---|
FormField |
텍스트 입력(Input) |
register |
FormSelectField |
네이티브 <select>(NativeSelect) |
register |
FormFilterableSelectField |
검색 가능한 콤보박스(FilterableSelect) |
control (useController) |
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/>- 값과 변경을
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} /><FilterableSelectoptions={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}/> - 새로운 입력 종류가 필요하면 화면에서 직접
Controller를 쓰지 말고, 위 세 컴포넌트와 같은 형태 (FieldRoot+FormLabel+ 입력 +FieldErrorText)로 공통 폼 필드를components/molecules에 추가한다.
- 제출 핸들러는
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> - 폼 값과 요청 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,}} - 모달의 확인 버튼에 연결할 때는
handleSubmit으로 감싼 함수를Modal의handleConfirm에 그대로 넘긴다. 성공했을 때만 모달을 닫고, 실패하면 모달을 열어둔 채 사용자가 입력을 고쳐 다시 시도하게 한다. 자세한 예시는 모달 사용 예제 참고.
수정 모드 초기값 채우기
Section titled “수정 모드 초기값 채우기”- 하나의 폼으로 등록과 수정을 함께 처리하면, 모달이 열릴 때마다
reset으로 폼을 다시 채운다 — 수정 모드면 응답 DTO를 폼 값으로 바꾼 값으로, 등록 모드면 초기값 상수로 채운다.pages/word/domain/components/DomainFormModal.tsx useEffect(() => {if (open) {reset(domain ? toDomainFormValues(domain) : DOMAIN_FORM_INITIAL)}}, [open, domain, reset]) - 응답 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 ?? '',}}
검색 조건 입력에 쓰기
Section titled “검색 조건 입력에 쓰기”- 목록 화면의 검색 조건(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> 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()}- 한 번의 입력이 여러 필드를 바꾸는 경우(등록일 범위
[fromDt, toDt])는 별도 핸들러에서setValue를 필드마다 호출한다.