최근 멀티 스텝 폼 기능을 구현하는 과제를 수행하면서, 처음으로 react-hook-form
과 zod
를 활용해봤다. 이를 통해 복잡한 폼 데이터 관리와 값 검증을 간편하게 처리할 수 있었으며, 구현 과정에서 궁금했던 기타 속성들도 함께 알아보고자 한다 🤔
과거에 한 서비스에서 유저 정보를 입력받는 기능을 구현했을 때, 제어 컴포넌트 방식으로 각 필드의 state
를 관리하고, 이를 검증하는 핸들러 함수들을 직접 만들었던 경험이 있다.
예를 들어, 다음과 같은 회원가입 폼을 구현할 때 필드별 state
와 핸들러를 만들어 관리하는 방식이다.
import { useState } from "react";
const SignUpForm = () => {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const handleUsernameChange = (e) => setUsername(e.target.value);
const handleEmailChange = (e) => {
setEmail(e.target.value);
setEmailError(e.target.value.includes("@") ? "" : "유효한 이메일을 입력하세요.");
};
const handlePasswordChange = (e) => setPassword(e.target.value);
const handleSubmit = (e) => {
e.preventDefault();
if (!username || !email || !password) {
alert("모든 필드를 입력하세요.");
return;
}
console.log("회원가입 성공!", { username, email, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>사용자 이름:</label>
<input type="text" value={username} onChange={handleUsernameChange} />
</div>
<div>
<label>이메일:</label>
<input type="email" value={email} onChange={handleEmailChange} />
{emailError && <p style={{ color: "red" }}>{emailError}</p>}
</div>
<div>
<label>비밀번호:</label>
<input type="password" value={password} onChange={handlePasswordChange} />
</div>
<button type="submit">가입하기</button>
</form>
);
};
export default SignUpForm;
위 코드는 onChange
이벤트가 발생할 때마다 state
를 업데이트하여 각 필드의 값을 관리하는 방식이다. 이처럼 value
속성을 state
로 제어하는 방식을 제어 컴포넌트라고 한다.
하지만 이 방식의 문제는 입력값이 변경될 때마다 불필요한 리렌더링이 발생한다는 점이다. 이를 해결하기 위해 비제어 컴포넌트 방식을 사용할 수 있다.
제어 컴포넌트는 state
를 활용해 입력값을 관리하지만, 비제어 컴포넌트는 ref
를 사용해 DOM 요소에 직접 접근한다.
위의 회원가입 폼을 비제어 방식으로 변환하면 다음과 같다.
import { useRef } from "react";
const SignUpForm = () => {
const usernameRef = useRef();
const emailRef = useRef();
const passwordRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
const username = usernameRef.current.value;
const email = emailRef.current.value;
const password = passwordRef.current.value;
if (!username || !email || !password) {
alert("모든 필드를 입력하세요.");
return;
}
if (!email.includes("@")) {
alert("유효한 이메일을 입력하세요.");
return;
}
console.log("회원가입 성공!", { username, email, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>사용자 이름:</label>
<input type="text" ref={usernameRef} />
</div>
<div>
<label>이메일:</label>
<input type="email" ref={emailRef} />
</div>
<div>
<label>비밀번호:</label>
<input type="password" ref={passwordRef} />
</div>
<button type="submit">가입하기</button>
</form>
);
};
export default SignUpForm;
이 방식은 입력값을 state
로 관리하지 않으므로 각 입력 필드의 변경이 컴포넌트 리렌더링을 유발하지 않는다는 장점이 있다.
하지만 실시간 검증이 필요한 경우 ref.current.value
를 직접 체크해야 하므로 코드가 다소 복잡해질 수 있다.
이러한 문제를 해결하기 위해 React Hook Form이 등장했다.
React Hook Form is based on Uncontrolled Components, which gives you the ability to easily build an accessible custom form.
React Hook Form은 비제어 컴포넌트 기반으로 동작하면서도 제어 컴포넌트의 장점을 일부 유지할 수 있도록 설계된 라이브러리이다.
즉, ref
를 활용하여 입력값을 관리하면서도 state
를 최소화하여 성능을 최적화한다.
React Hook Form을 적용한 회원가입 컴포넌트는 아래와 같다.
import { useForm } from "react-hook-form";
const SignUpForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => console.log("회원가입 성공!", data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input placeholder="이름" {...register("username", { required: "이름을 입력하세요." })} />
{errors.username && <p style={{ color: "red" }}>{errors.username.message}</p>}
<input placeholder="이메일" {...register("email", {
required: "이메일을 입력하세요.",
pattern: { value: /^\S+@\S+$/, message: "유효한 이메일을 입력하세요." }
})} />
{errors.email && <p style={{ color: "red" }}>{errors.email.message}</p>}
<input type="password" placeholder="비밀번호" {...register("password", { required: "비밀번호를 입력하세요." })} />
{errors.password && <p style={{ color: "red" }}>{errors.password.message}</p>}
<button type="submit">가입하기</button>
</form>
);
};
export default SignUpForm;
useForm
이라는 훅에서 제공하는 주요 메서드들을 통해 입력값의 추적과 검증, 그리고 에러 처리를 간단하게 할 수 있음을 살펴볼 수 있다.
아래의 기본 사용법과 함께 이해해보자!
useForm()
훅const { register, handleSubmit, formState: { errors } } = useForm();
위와 같이 useForm을 호출하면 폼 데이터를 다룰 수 있는 여러 메서드들과 현재 폼의 상태를 나타내는 속성들을 객체로 받을 수 있다.
현재 글은 react hook form을 처음 사용해보는 단계이므로 주요 메서드와 속성을 위주로 살펴보고자 한다.
register()
<input {...register("username", { required: "이름을 입력하세요." })} />
<input {...register("lastName", { minLength: 2 })} placeholder="Last name" />
register()
로 연결하면 해당 값을 자동으로 관리할 수 있다.handleSubmit()
<form onSubmit={handleSubmit(onSubmit)}>
event.preventDefault()
를 실행한다.onSubmit
함수가 실행된다.formState
<p>폼 변경 여부: {isDirty ? "변경됨" : "변경되지 않음"}</p>
<p>유효한 폼 여부: {isValid ? "유효함" : "유효하지 않음"}</p>
<p>제출 횟수: {submitCount}</p>
{errors.username && <p style={{ color: "red" }}>{errors.username.message}</p>}
이름 | 타입 | 설명 |
---|---|---|
isDirty | boolean | 사용자가 하나라도 입력 필드를 변경하면 true 가 됨 |
dirtyFields | object | 변경된 필드들을 객체 형태로 저장 |
touchedFields | object | 사용자가 한 번이라도 클릭(포커스)한 필드들을 저장 |
defaultValues | object | useForm 에서 설정한 초기값 |
isSubmitted | boolean | 폼이 제출되었는지 여부 |
isSubmitSuccessful | boolean | 폼이 정상적으로 제출되었는지 여부 |
isSubmitting | boolean | 폼이 현재 제출 중인지 여부 |
isLoading | boolean | 비동기 defaultValues 를 로드하는 중인지 여부 |
submitCount | number | 폼이 제출된 횟수 |
isValid | boolean | 폼에 유효성 검사 오류가 없으면 true |
isValidating | boolean | 현재 유효성 검사가 진행 중인지 여부 |
errors | object | 각 필드의 유효성 검사 오류를 저장하는 객체 |
disabled | boolean | useForm 의 disabled 속성이 적용된 경우 true |
useForm
의 주요 설정 옵션useForm
을 사용할 때 다양한 설정 옵션을 제공하여 폼의 동작을 조정할 수 있다.
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
mode: "onChange",
defaultValues: {
username: "",
email: "",
},
reValidateMode: "onBlur",
});
옵션 | 타입 | 설명 |
---|---|---|
mode | onBlur , onChange , onSubmit , all | 유효성 검사가 실행되는 시점을 설정. 기본값은 "onSubmit" |
reValidateMode | onBlur , onChange , onSubmit | 이미 유효성 검사가 끝난 필드에 대해 다시 검사를 수행하는 시점 |
defaultValues | object | 폼 필드의 초기값을 설정 |
resolver | function | Yup, Zod 등과 같은 외부 라이브러리를 사용하여 유효성 검사를 수행 |
shouldUnregister | boolean | true로 설정하면, 컴포넌트가 언마운트될 때 필드 값을 폼 상태에서 제거 |
criteriaMode | firstError , all | "all"로 설정하면, 필드의 모든 유효성 검사 오류를 반환 |
React Hook Form은 기본적으로 register
를 통해 입력 필드의 값을 관리하고, formState.errors
를 활용해 간단한 검증을 처리할 수 있다.
import { useForm } from "react-hook-form";
const SignUpForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => console.log("회원가입 성공!", data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
placeholder="이메일"
{...register("email", {
required: "이메일을 입력하세요.",
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "유효한 이메일을 입력하세요." }
})}
/>
{errors.email && <p style={{ color: "red" }}>{errors.email.message}</p>}
<button type="submit">가입하기</button>
</form>
);
};
export default SignUpForm;
위에서 알아본 코드에서는 register
를 통해 입력 필드를 등록하고, required
와 pattern
, minLength
등의 기본 검증 옵션을 활용하여 유효성을 검사할 수 있다. 하지만 입력 필드가 많아질수록 이러한 방식은 다소 복잡해질 수 있다.
따라서 여러 개의 필드에 대해 복잡한 검증을 처리하려면 Zod 같은 타입스크립트 기반의 스키마 선언 및 데이터 검증 라이브러리를 활용하는 것이 좋다. Zod는 직관적인 문법으로 스키마를 정의하고, 타입 검증을 간편하게 수행할 수 있도록 도와준다.
아래는 공식 문서의 기본 사용법이다.
import { z } from "zod";
// 문자열을 위한 스키마 생성
const mySchema = z.string();
// 파싱
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => ZodError 발생
// "안전한" 파싱 (검증 실패 시 에러를 던지지 않음)
mySchema.safeParse("tuna"); // => { success: true; data: "tuna" }
mySchema.safeParse(12); // => { success: false; error: ZodError }
객체 형태의 데이터를 검증하려면 z.object()를 사용하면 된다.
예제에서는 User라는 객체를 정의하고, username 필드를 문자열로 제한하는 스키마를 만든다.
import { z } from "zod";
const User = z.object({
username: z.string(),
});
User.parse({ username: "Ludwig" });
// 추론된 타입 추출
type User = z.infer;
// { username: string }
이러한 방식으로 객체 스키마를 만들면, User.parse()를 이용해 입력된 데이터가 스키마를 만족하는지 검증할 수 있으며, z.infer<typeof User>
를 사용하여 타입스크립트에서 유추된 타입을 활용할 수 있다!
이제 간단하게 학습한 내용을 바탕으로 여러 스텝에 걸쳐 폼을 입력받는 기능을 구현해보자.
우선 모든 스텝에 걸쳐서 입력받을 필드값에 대한 스키마를 작성하고, infer
를 통해 타입을 추출해주었다.
export const FormDataSchema = z.object({
consent: z.literal('true', {
errorMap: () => ({ message: "개인정보 수집 동의는 필수입니다." })
}),
name: z.string().min(2, { message: "이름은 최소 2자 이상이어야 합니다." }).max(50, { message: "이름은 최대 50자 이하이어야 합니다." }),
email: z.string().email({ message: "유효한 이메일 주소를 입력해주세요." }),
phone: z.string().regex(/\d{3}-\d{3,4}-\d{4}/, { message: "전화번호 형식이 올바르지 않습니다." }),
part: z.enum(["frontend", "backend", "design"], {
errorMap: () => ({ message: "지원 분야를 선택해주세요." })
})
});
export type Inputs = z.infer<typeof FormDataSchema>;
useForm
의 config
중 resolver
에 zodResolver(FormDataSchema)
를 설정하여 Zod의 검증 로직을 통합해준다.
const {
register,
handleSubmit,
trigger,
getValues,
formState: { errors }
} = useForm<Inputs>({ mode: 'onChange', resolver: zodResolver(FormDataSchema) });
mode: 'onChange'
를 추가해줬다.onBlur
또는 onSubmit
모드와 다르게 실시간 피드백 제공이 가능하지만 사용자 입력 변화를 계속해서 추적하기 때문에 추가적인 리렌더링이 불가피하다는 문제가 있었다. (해당 문제를 더 우아하게 해결할 수 있을지는 이후에 더 고민해보고자 한다 🤔)각 스텝 별로 반환할 컴포넌트와 해당 스텝에 사용되는 필드 이름을 담은 배열을 선언해주었다.
const steps = [
{ id: 1, name: '개인정보 수집 동의', component: ConsentStep, fields: ['consent'] },
{ id: 2, name: '기본 정보', component: BasicInfoStep, fields: ['name', 'email', 'phone'] },
{ id: 3, name: '지원 정보', component: ApplicationInfoStep, fields: ['part'] }
] as const;
폼의 스텝을 관리하는 커스텀 훅 useFormStep을 정의해주었다.
import { useState } from 'react';
interface UseFormStepParams {
totalSteps: number;
}
function useFormStep({ totalSteps }: UseFormStepParams) {
const [currentStep, setCurrentStep] = useState(0);
const [previousStep, setPreviousStep] = useState(0);
const [showError, setShowError] = useState(false);
/**
* 필드값 검증 후 다음 스텝으로 넘어가는 함수입니다.
* @param validateFields 필드값 검증 결과를 반환하는 함수
* @param finalStepAction 마지막 스텝에서 추가적인 액션을 진행하는 함수
* @returns
*/
const next = async (validateFields?: () => Promise<boolean>, finalStepAction?: () => void) => {
if (validateFields) {
const isValid = await validateFields();
setShowError(true);
if (!isValid) return;
}
if (currentStep === totalSteps - 1 && finalStepAction) {
finalStepAction();
return;
}
setShowError(false);
setPreviousStep(currentStep);
setCurrentStep((prevStep) => prevStep + 1);
};
/**
* 이전 스텝으로 이동하는 함수
* @param callback
*/
const prev = (callback?: () => void) => {
if (callback) callback();
if (currentStep > 0) {
setPreviousStep(currentStep);
setCurrentStep((prevStep) => prevStep - 1);
}
};
return {
currentStep,
previousStep,
showError,
next,
prev
};
}
export default useFormStep;
현재 스텝의 값이 유효하지 않으면 넘어가지 못하도록 해야 하기 때문에, 특정 스텝 필드에 대한 유효성 검사를 진행할 수 있도록 trigger
를 활용해주었다.
3번에서 정의한 스텝에 포함된 필드에 대해서만 trigger
를 실행하며, 검증이 통과되면 다음 단계로 이동할 수 있도록 next
함수를 호출한다.
const validateFields = async () => {
const fields = steps[currentStep].fields;
const isValid = await trigger(fields, { shouldFocus: true });
return isValid;
};
const finalStepAction = () => openModal();
const handleNext = () => next(validateFields, finalStepAction);
const handlePrev = () => prev();
최종적으로 데이터를 서버로 전송하는 로직을 구현했다.
const processForm: SubmitHandler<Inputs> = async (data) => {
try {
const formattedData = {
...data,
consent: JSON.parse(data.consent as unknown as string)
};
const response = await sendApplicationForm(formattedData);
console.log('서버 응답:', response);
setIsSubmitted(true);
} catch (error) {
console.error(error);
alert('폼 제출 중 오류가 발생했습니다.');
}
};
const steps = [
{ id: 1, name: '개인정보 수집 동의', component: ConsentStep, fields: ['consent'] },
{ id: 2, name: '기본 정보', component: BasicInfoStep, fields: ['name', 'email', 'phone'] },
{ id: 3, name: '지원 정보', component: ApplicationInfoStep, fields: ['part'] }
] as const;
function Form() {
const [isSubmitted, setIsSubmitted] = useState(false);
const { isOpen, closeModal, openModal } = useModal();
const { currentStep, showError, next, prev } = useFormStep({ totalSteps: steps.length });
const {
register,
handleSubmit,
trigger,
getValues,
formState: { errors }
} = useForm<Inputs>({ mode: 'onChange', resolver: zodResolver(FormDataSchema) });
type FieldName = keyof Inputs;
const processForm: SubmitHandler<Inputs> = async (data) => {
try {
const formattedData = {
...data,
consent: JSON.parse(data.consent as unknown as string)
};
const response = await sendApplicationForm(formattedData);
console.log('서버 응답:', response);
setIsSubmitted(true);
} catch (error) {
console.error(error);
alert('폼 제출 중 오류가 발생했습니다.');
}
};
const validateFields = async () => {
const fields = steps[currentStep].fields;
const isValid = await trigger(fields as readonly FieldName[], { shouldFocus: true });
return isValid;
};
const finalStepAction = () => openModal();
const handleNext = () => next(validateFields, finalStepAction);
const handlePrev = () => prev();
const CurrentComponent = steps[currentStep].component;
return (
<>
{!isSubmitted && (
<>
<ProgressBar totalSteps={steps.length} currentStep={currentStep} />
<form>
<CurrentComponent register={register} errors={errors} showError={showError} />
</form>
<Box className="flex justify-between">
<Button text="뒤로" onClick={handlePrev} disabled={currentStep === 0} />
<Button text={currentStep < steps.length - 1 ? '다음' : '제출하기'} onClick={handleNext} />
</Box>
</>
)}
{isSubmitted && <CompleteStep />}
{isOpen && (
<ConfirmModal
title="제출 전 확인해주세요!"
description={transformFormData(getValues())}
leftBtnText="취소"
rightBtnText="제출"
rightBtnAction={handleSubmit(processForm)}
closeModal={closeModal}
/>
)}
</>
);
}
export default Form;
이제 사용자는 각 스텝에서 필요한 정보를 입력하면서 실시간 검증을 통해 에러를 바로 확인할 수 있으며, 불필요한 에러 메시지는 표시되지 않도록 제어할 수 있게 되었다.
그동안 React Hook Form을 써봐야겠다고 생각만 하다가, 이번 기회를 통해 직접 학습하고 멀티 스텝 폼이라는 비교적 복잡한 기능을 구현해볼 수 있었다.
짧은 기간 내에 구현해야 하는 상황이라 라이브러리에 대한 이해가 충분하지 않은 상태에서 적용한 점이 아쉽지만, React Hook Form이 직관적이고 사용하기 쉬운 라이브러리라서 비교적 빠르게 적용할 수 있었다. 앞으로도 프로젝트에서 활용하며 다양한 속성과 기능을 더 깊이 이해하고, 실무에서 활용할 수 있도록 익숙해지고 싶다.
또한, 라이브러리를 사용하지 않고 직접 확장성과 유지보수가 용이한 구조로 구현할 방법에 대한 고민도 남아 있다. 이에 대한 힌트를 얻고 싶다면, 최근 토스 Frontend Diving Club의 발표 자료를 참고해보는 것도 도움이 될 것 같다.
(고통받는 모 개발자의 증언)
복잡한 폼을 더 우아하게 다룰 수 있는 날까지... 파이팅! 🔥
https://react.dev/learn/sharing-state-between-components