폼 상태 커스텀 훅과 라이브러리
폼 상태와 검증을 커스텀 훅으로 추상화하고 폼 라이브러리가 필요한 기준을 판단합니다.
앞 절의 ReducerForm에서 값 변경·오류 저장·초기화를 나누어 본 뒤 읽으세요. 여기서는 훅의 입력을 initialValues와 validate, 출력을 values·errors·handlers로 정하고, 제출 성공·실패를 화면에서 확인합니다. 같은 훅을 호출한 서로 다른 폼이 자동으로 한 state를 공유하는 것은 아닙니다.
커스텀 훅을 이용한 폼 상태 추상화
useForm 값·검증·제출 상태 계약 — 상태 추적
현재 values 검증 뒤 오류면 callback을 부르지 않고, 통과하면 기다린 뒤 성공 정책 또는 errors.submit 및 finally로 종료한다. 제출 guard는 실제 구현에 없어 엄격한 중복 차단으로 주장하지 않는다.
React · Custom hook submit contract
useForm 예제의 제출 경로와 확장할 상태
현재 useForm 예제의 기준선은 values → errors → submit result 흐름입니다. 방문 여부, 동시 제출 방지, 서버 필드 오류는 자동으로 생기지 않으므로 별도 상태와 정책으로 추가합니다.
입력 변경에서 제출 종료까지
handleChange가 한 필드의values를 갱신name을 key로 사용하고 checkbox만checkedboolean을 읽습니다.제출 시 현재
values로 새validationErrors를 계산isSubmitting을 켠 뒤 계산 결과를errors에 저장합니다.오류가 있으면 callback을 실행하지 않음
오류가 없을 때만 호출자가 제공한 비동기 제출 callback을
await합니다.예외는 일반 제출 오류로 기록하고
finally에서 종료성공한 callback은 필요할 때
resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다.
| 상태 축 | 현재 예제 | 복잡도가 커질 때 추가할 계약 |
|---|---|---|
values | 입력 이벤트가 만드는 현재 값의 원천 | 동적 필드의 기본값·등록 해제·배열 key 정책 |
errors | validate(values)가 만든 클라이언트 오류와 일반 submit 오류 | 스키마 오류와 서버 오류를 필드·폼 수준으로 명시적으로 매핑 |
touched · dirty | 현재 훅은 추적하지 않음 | touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 구분 |
| isSubmitting | 버튼 비활성화와 진행 문구를 위한 화면 상태 | 동시 요청 guard와 idempotency는 별도 설계 |
현재 값의 원천
입력 변경은 해당 필드를 갱신하고 검증은 이 snapshot을 읽습니다. 동적 필드는 기본값·등록 해제·배열 key 정책을 추가합니다.
계산 결과와 제출 오류
현재 예제는 클라이언트 검증 오류와 일반 제출 오류를 저장합니다. 스키마·서버 오류의 필드·폼 매핑은 별도 계약입니다.
현재 예제 밖의 축
현재 훅은 둘 다 추적하지 않습니다. touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 따로 정의합니다.
진행 표시와 복구
finally로 상태를 복구하되 동시 요청 차단은 별도 guard가 필요합니다.
명시적인 매핑
서버 업무 규칙 실패를 필드 또는 폼 오류로 옮기는 정책을 따로 둡니다.
화면의 disabled는 사용자 피드백일 뿐 요청 중복 방지를 보장하지 않습니다. 제출 guard와 서버 idempotency가 필요하면 훅 계약에 명시합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | values→검증 결과에서 오류가 있으면 callback 없이 종료하고, 오류가 없으면 callback을 기다린다. callback 성공의 resetForm은 호출자가 선택하는 정책이고 예외는 errors.submit으로 간다. 모든 제출 경로에서 finally가 isSubmitting을 false로 돌린다. 중복 요청 guard는 구현하지 않은 별도 계약이다. |
| 원도표 설명 | handleChange가 한 필드의 values를 갱신 |
| 원도표 설명 | name을 key로 사용하고 checkbox만 checked boolean을 읽습니다. |
| 원도표 조건·주석 | handleChange가 한 필드의 values를 갱신: name을 key로 사용하고 checkbox만 checked boolean을 읽습니다. |
| 원도표 설명 | 제출 시 현재 values로 새 validationErrors를 계산 |
| 원도표 설명 | isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다. |
| 원도표 조건·주석 | 제출 시 현재 values로 새 validationErrors를 계산: isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다. |
| 원도표 설명 | 오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다. |
| 원도표 조건·주석 | 오류가 있으면 callback을 실행하지 않음: 오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다. |
| 원도표 설명 | 예외는 일반 제출 오류로 기록하고 finally에서 종료 |
| 원도표 설명 | 성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다. |
| 원도표 조건·주석 | 예외는 일반 제출 오류로 기록하고 finally에서 종료: 성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다. |
| 원도표 표 행 | 상태 축 | 현재 예제 | 복잡도가 커질 때 추가할 계약 |
| 원도표 표 행 | values | 입력 이벤트가 만드는 현재 값의 원천 | 동적 필드의 기본값·등록 해제·배열 key 정책 |
| 원도표 표 행 | errors | validate(values)가 만든 클라이언트 오류와 일반 submit 오류 | 스키마 오류와 서버 오류를 필드·폼 수준으로 명시적으로 매핑 |
| 원도표 표 행 | touched · dirty | 현재 훅은 추적하지 않음 | touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 구분 |
| 원도표 표 행 | isSubmitting | 버튼 비활성화와 진행 문구를 위한 화면 상태 | 동시 요청 guard와 idempotency는 별도 설계 |
| 원도표 설명 | finally로 상태를 복구하되 동시 요청 차단은 별도 guard가 필요합니다. |
| 원도표 설명 | 화면의 disabled는 사용자 피드백일 뿐 요청 중복 방지를 보장하지 않습니다. 제출 guard와 서버 idempotency가 필요하면 훅 계약에 명시합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 values 검사 | 검사: validate(values) / invalid: callback 호출 없음 | submit에서 현재 값의 오류를 계산한다 | 표시된 errors만 읽어 제출을 결정하지 않는다 |
| valid callback 대기 | isSubmitting: 진행 표시 / callback: pending | 오류가 없으면 callback의 완료를 기다린다 | 실제 중복 guard는 구현되지 않은 확장이다 |
| 성공·실패 종료 | 성공: 선택 reset / 실패: submit 오류 | 성공 reset은 호출 정책, 예외는 errors.submit이며 finally가 진행 표시를 푼다 | 실패에서 값을 무조건 초기화하지 않는다 |
useForm 값·검증·제출 상태 계약 — 상태 추적
현재 values 검증 뒤 오류면 callback을 부르지 않고, 통과하면 기다린 뒤 성공 정책 또는 errors.submit 및 finally로 종료한다. 제출 guard는 실제 구현에 없어 엄격한 중복 차단으로 주장하지 않는다.
React · Custom hook submit contract
useForm 예제의 제출 경로와 확장할 상태
현재 useForm 예제의 기준선은 values → errors → submit result 흐름입니다. 방문 여부, 동시 제출 방지, 서버 필드 오류는 자동으로 생기지 않으므로 별도 상태와 정책으로 추가합니다.
입력 변경에서 제출 종료까지
handleChange가 한 필드의values를 갱신name을 key로 사용하고 checkbox만checkedboolean을 읽습니다.제출 시 현재
values로 새validationErrors를 계산isSubmitting을 켠 뒤 계산 결과를errors에 저장합니다.오류가 있으면 callback을 실행하지 않음
오류가 없을 때만 호출자가 제공한 비동기 제출 callback을
await합니다.예외는 일반 제출 오류로 기록하고
finally에서 종료성공한 callback은 필요할 때
resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다.
| 상태 축 | 현재 예제 | 복잡도가 커질 때 추가할 계약 |
|---|---|---|
values | 입력 이벤트가 만드는 현재 값의 원천 | 동적 필드의 기본값·등록 해제·배열 key 정책 |
errors | validate(values)가 만든 클라이언트 오류와 일반 submit 오류 | 스키마 오류와 서버 오류를 필드·폼 수준으로 명시적으로 매핑 |
touched · dirty | 현재 훅은 추적하지 않음 | touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 구분 |
| isSubmitting | 버튼 비활성화와 진행 문구를 위한 화면 상태 | 동시 요청 guard와 idempotency는 별도 설계 |
현재 값의 원천
입력 변경은 해당 필드를 갱신하고 검증은 이 snapshot을 읽습니다. 동적 필드는 기본값·등록 해제·배열 key 정책을 추가합니다.
계산 결과와 제출 오류
현재 예제는 클라이언트 검증 오류와 일반 제출 오류를 저장합니다. 스키마·서버 오류의 필드·폼 매핑은 별도 계약입니다.
현재 예제 밖의 축
현재 훅은 둘 다 추적하지 않습니다. touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 따로 정의합니다.
진행 표시와 복구
finally로 상태를 복구하되 동시 요청 차단은 별도 guard가 필요합니다.
명시적인 매핑
서버 업무 규칙 실패를 필드 또는 폼 오류로 옮기는 정책을 따로 둡니다.
화면의 disabled는 사용자 피드백일 뿐 요청 중복 방지를 보장하지 않습니다. 제출 guard와 서버 idempotency가 필요하면 훅 계약에 명시합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | values→검증 결과에서 오류가 있으면 callback 없이 종료하고, 오류가 없으면 callback을 기다린다. callback 성공의 resetForm은 호출자가 선택하는 정책이고 예외는 errors.submit으로 간다. 모든 제출 경로에서 finally가 isSubmitting을 false로 돌린다. 중복 요청 guard는 구현하지 않은 별도 계약이다. |
| 원도표 설명 | handleChange가 한 필드의 values를 갱신 |
| 원도표 설명 | name을 key로 사용하고 checkbox만 checked boolean을 읽습니다. |
| 원도표 조건·주석 | handleChange가 한 필드의 values를 갱신: name을 key로 사용하고 checkbox만 checked boolean을 읽습니다. |
| 원도표 설명 | 제출 시 현재 values로 새 validationErrors를 계산 |
| 원도표 설명 | isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다. |
| 원도표 조건·주석 | 제출 시 현재 values로 새 validationErrors를 계산: isSubmitting을 켠 뒤 계산 결과를 errors에 저장합니다. |
| 원도표 설명 | 오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다. |
| 원도표 조건·주석 | 오류가 있으면 callback을 실행하지 않음: 오류가 없을 때만 호출자가 제공한 비동기 제출 callback을 await합니다. |
| 원도표 설명 | 예외는 일반 제출 오류로 기록하고 finally에서 종료 |
| 원도표 설명 | 성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다. |
| 원도표 조건·주석 | 예외는 일반 제출 오류로 기록하고 finally에서 종료: 성공한 callback은 필요할 때 resetForm을 호출하고, 모든 경로에서 제출 상태를 복구합니다. |
| 원도표 표 행 | 상태 축 | 현재 예제 | 복잡도가 커질 때 추가할 계약 |
| 원도표 표 행 | values | 입력 이벤트가 만드는 현재 값의 원천 | 동적 필드의 기본값·등록 해제·배열 key 정책 |
| 원도표 표 행 | errors | validate(values)가 만든 클라이언트 오류와 일반 submit 오류 | 스키마 오류와 서버 오류를 필드·폼 수준으로 명시적으로 매핑 |
| 원도표 표 행 | touched · dirty | 현재 훅은 추적하지 않음 | touched는 blur 등 방문 전이, dirty는 초기값과 현재 값 비교로 구분 |
| 원도표 표 행 | isSubmitting | 버튼 비활성화와 진행 문구를 위한 화면 상태 | 동시 요청 guard와 idempotency는 별도 설계 |
| 원도표 설명 | finally로 상태를 복구하되 동시 요청 차단은 별도 guard가 필요합니다. |
| 원도표 설명 | 화면의 disabled는 사용자 피드백일 뿐 요청 중복 방지를 보장하지 않습니다. 제출 guard와 서버 idempotency가 필요하면 훅 계약에 명시합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 현재 values 검사 | 검사: validate(values) / invalid: callback 호출 없음 | submit에서 현재 값의 오류를 계산한다 | 표시된 errors만 읽어 제출을 결정하지 않는다 |
| valid callback 대기 | isSubmitting: 진행 표시 / callback: pending | 오류가 없으면 callback의 완료를 기다린다 | 실제 중복 guard는 구현되지 않은 확장이다 |
| 성공·실패 종료 | 성공: 선택 reset / 실패: submit 오류 | 성공 reset은 호출 정책, 예외는 errors.submit이며 finally가 진행 표시를 푼다 | 실패에서 값을 무조건 초기화하지 않는다 |
useReducer를 사용하더라도, 폼마다 리듀서와 handleChange 로직을 작성하는 것은 여전히 반복적인 작업일 수 있습니다.
이때 커스텀 훅(Custom Hook)을 사용하면 이러한 폼 상태 관리 로직을 추상화하여 재사용성을 극대화할 수 있습니다.
useForm과 같은 커스텀 훅은 다음과 같은 기능을 제공할 수 있습니다.
- 폼 데이터 상태 관리 (
useState또는useReducer기반) - 모든
input필드에 적용할 수 있는 범용handleChange함수 - 폼 제출(
handleSubmit) 시 데이터를 처리하는 로직 - 유효성 검사 로직 및 에러 상태 관리
useForm 커스텀 훅 예시
import { useState, useCallback } from 'react';
/**
* 폼 상태와 유효성 검사를 관리하는 커스텀 훅
* @param {object} initialValues - 폼 필드의 초기 값
* @param {function} validate - 폼 데이터 객체를 받아 에러 객체를 반환하는 유효성 검사 함수
* @returns {{ values, errors, handleChange, handleSubmit, resetForm }}
*/
const useForm = (initialValues, validate) => {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false); // 제출 중 상태 (선택 사항)
const handleChange = useCallback((e) => {
const { name, value, type, checked } = e.currentTarget;
setValues(prevValues => ({
...prevValues,
[name]: type === 'checkbox' ? checked : value,
}));
// 입력 시 실시간 유효성 검사를 하고 싶다면 여기에 validate 로직을 추가할 수 있습니다.
// 하지만 보통은 제출 시점에 모든 유효성을 검사하는 것이 일반적입니다.
}, []);
const handleSubmit = useCallback((callback) => async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
const validationErrors = validate(values); // 유효성 검사 실행
setErrors(validationErrors);
if (Object.keys(validationErrors).length === 0) {
// 에러가 없으면 콜백 함수 실행
await callback(values); // 비동기 콜백을 지원하기 위해 await
}
} catch (submitError) {
// 검증 또는 제출 콜백에서 발생한 오류 처리 (예: API 제출 실패)
console.error("폼 제출 오류:", submitError);
setErrors(prevErrors => ({
...prevErrors,
submit: submitError.message || '폼 제출 중 오류가 발생했습니다.'
}));
} finally {
setIsSubmitting(false);
}
}, [values, validate]);
const resetForm = useCallback(() => {
setValues(initialValues);
setErrors({});
setIsSubmitting(false);
}, [initialValues]);
return {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
resetForm,
};
};
export default useForm;useForm 커스텀 훅 사용 예시
import React from 'react';
import useForm from '../hooks/useForm'; // 커스텀 훅 임포트
// 유효성 검사 함수 (useForm에 전달될 콜백)
const validateUserInfo = (values) => {
const errors = {};
if (!values.username.trim()) {
errors.username = '사용자 이름은 필수입니다.';
} else if (values.username.length < 3) {
errors.username = '사용자 이름은 3자 이상이어야 합니다.';
}
if (!values.email.trim()) {
errors.email = '이메일은 필수입니다.';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = '유효한 이메일 주소를 입력해주세요.';
}
if (!values.password) {
errors.password = '비밀번호는 필수입니다.';
} else if (values.password.length < 6) {
errors.password = '비밀번호는 6자 이상이어야 합니다.';
}
if (values.password !== values.confirmPassword) {
errors.confirmPassword = '비밀번호가 일치하지 않습니다.';
}
return errors;
};
function UserRegistrationForm() {
const {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
resetForm,
} = useForm(
{ username: '', email: '', password: '', confirmPassword: '' }, // 초기값
validateUserInfo // 유효성 검사 함수
);
// 폼 제출 로직 (useForm의 handleSubmit에 전달될 콜백)
const onSubmit = async (formData) => {
// 실제 API 호출 로직을 여기에 작성
console.log('폼 데이터 제출 시작:', formData);
try {
// 가상 API 호출 지연
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('폼 데이터 제출 완료:', formData);
alert('회원가입이 완료되었습니다!');
resetForm(); // 제출 성공 후 폼 초기화
} catch (error) {
console.error('회원가입 실패:', error);
alert('회원가입에 실패했습니다.');
throw error; // useForm 훅에서 에러를 잡을 수 있도록 다시 던짐
}
};
return (
<div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>회원가입 폼 (커스텀 훅)</h2>
<form onSubmit={handleSubmit(onSubmit)} aria-busy={isSubmitting}> {/* handleSubmit에 실제 제출 함수를 전달 */}
<div style={{ marginBottom: '15px' }}>
<label htmlFor="registration-username" style={{ display: 'block', marginBottom: '5px' }}>사용자 이름:</label>
<input
id="registration-username"
type="text"
name="username"
value={values.username}
onChange={handleChange}
aria-invalid={Boolean(errors.username)}
aria-describedby={errors.username ? 'registration-username-error' : undefined}
style={{ width: '100%', padding: '8px', border: errors.username ? '1px solid red' : '1px solid #ccc' }}
/>
{errors.username && <p id="registration-username-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.username}</p>}
</div>
<div style={{ marginBottom: '15px' }}>
<label htmlFor="registration-email" style={{ display: 'block', marginBottom: '5px' }}>이메일:</label>
<input
id="registration-email"
type="email"
name="email"
value={values.email}
onChange={handleChange}
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'registration-email-error' : undefined}
style={{ width: '100%', padding: '8px', border: errors.email ? '1px solid red' : '1px solid #ccc' }}
/>
{errors.email && <p id="registration-email-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.email}</p>}
</div>
<div style={{ marginBottom: '15px' }}>
<label htmlFor="registration-password" style={{ display: 'block', marginBottom: '5px' }}>비밀번호:</label>
<input
id="registration-password"
type="password"
name="password"
value={values.password}
onChange={handleChange}
aria-invalid={Boolean(errors.password)}
aria-describedby={errors.password ? 'registration-password-error' : undefined}
style={{ width: '100%', padding: '8px', border: errors.password ? '1px solid red' : '1px solid #ccc' }}
/>
{errors.password && <p id="registration-password-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.password}</p>}
</div>
<div style={{ marginBottom: '15px' }}>
<label htmlFor="registration-confirm-password" style={{ display: 'block', marginBottom: '5px' }}>비밀번호 확인:</label>
<input
id="registration-confirm-password"
type="password"
name="confirmPassword"
value={values.confirmPassword}
onChange={handleChange}
aria-invalid={Boolean(errors.confirmPassword)}
aria-describedby={errors.confirmPassword ? 'registration-confirm-password-error' : undefined}
style={{ width: '100%', padding: '8px', border: errors.confirmPassword ? '1px solid red' : '1px solid #ccc' }}
/>
{errors.confirmPassword && <p id="registration-confirm-password-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.confirmPassword}</p>}
</div>
{errors.submit && <p role="alert" style={{ color: 'red', fontSize: '0.9em', marginTop: '10px' }}>{errors.submit}</p>} {/* 제출 오류 */}
<button type="submit" className="button" disabled={isSubmitting} style={{ width: '100%', padding: '10px' }}>
{isSubmitting ? '등록 중...' : '회원가입'}
</button>
</form>
</div>
);
}
export default UserRegistrationForm;이 useForm 커스텀 훅은 폼 컴포넌트의 로직을 훨씬 간결하고 재사용 가능하게 만듭니다.
이제 각 폼 컴포넌트는 오직 자신의 폼 필드와 유효성 검사 규칙만 정의하면 됩니다.
두 파일을 연결해 성공과 실패를 확인하기
useForm은 JSX가 없는 훅 파일, UserRegistrationForm은 JSX가 있는 화면 파일로 나눕니다. Vite 복사본은 src/hooks/useForm.js와 src/components/UserRegistrationForm.jsx에 각각 원 블록을 저장하고 App에서 UserRegistrationForm을 렌더합니다. 원 submit의 1초 대기는 setTimeout을 기다리는 모형이며 실제 API에 회원을 생성하지 않습니다. 성공 alert 뒤 resetForm이 초기 네 빈 값과 빈 errors로 돌아갑니다. 비밀번호가 들어가는 원 콘솔 관찰문은 실행 복사본에서 제거하고 성공·실패 종류만 관찰하세요.
예시
username Mina, email [email protected], password abcdef, confirmPassword abcdef를 입력하면 네 규칙을 통과합니다. 제출 callback을 기다리는 동안 진행 문구가 보이고 완료 뒤 초기화됩니다. confirmPassword만 abcdeg로 바꾸면 해당 필드 오류가 생기고 callback은 호출하지 않습니다. disabled가 화면에 반영되기 전 같은 handler를 다시 직접 호출하는 경우를 원 코드가 별도 ref guard로 차단하지는 않습니다.
원 username 검사는 trim으로 빈 값 여부를 확인한 뒤 원 문자열의 length를 봅니다. 따라서 ' a '는 길이 3으로 통과합니다. 앞뒤 공백을 제외한 이름 세 글자라는 정책을 원한다면 복사본의 검증 함수에서 정규화한 값을 길이 검사에도 사용합니다. 검증 통과와 실제 저장 문자열의 정규화는 별도 결정입니다.
const normalizedUsername = values.username.trim();
// 원 validateUserInfo의 username 검사 두 조건만 이 값으로 비교합니다.
if (!normalizedUsername) {
errors.username = '사용자 이름은 필수입니다.';
} else if (normalizedUsername.length < 3) {
errors.username = '사용자 이름은 3자 이상이어야 합니다.';
}이 블록은 errors와 values가 있는 validateUserInfo 내부의 비교 부분이며 완성 컴포넌트가 아닙니다. 원 코드의 다른 필드 규칙을 삭제하지 않고 복사본에서 두 조건만 교체합니다.
제출 callback을 바꾼 독립 연습
유효한 네 값을 유지한 채 callback이 Error('연습용 제출 실패')를 던지도록 복사본에서 바꿉니다. errors는 어디에 생기고 isSubmitting과 values는 어떻게 끝나나요? 검증이 실패한 경우 callback 호출 수는 얼마인가요? callback이 성공하되 resetForm을 호출하지 않으면 값을 누가 초기화해야 하나요?
훅과 호출자의 책임을 나눈 풀이
유효한 입력에서는 callback을 await하다 예외를 받아 errors.submit에 연습용 제출 실패가 저장됩니다. finally가 isSubmitting을 false로 돌리고 원 값은 유지됩니다. 서버 email 오류로 자동 매핑하지 않으며, 이 예시는 Error 객체를 던지는 조건입니다. 임의의 null 같은 값도 던질 수 있는 경계를 받는다면 catch의 message 읽기에 타입 검사를 추가해야 합니다.
검증 오류가 하나라도 있으면 callback 호출 수는 0입니다. 오류 없는 callback이 resolve해도 훅 자체는 성공 시 values를 비우지 않습니다. 호출자가 resetForm을 호출하는 정책을 선택합니다. 원 사용자 폼은 성공 직전에 resetForm을 호출하지만, 기다리는 동안 사용자가 새 입력을 하면 그 입력도 초기화할 수 있으므로 실제 서비스에서는 입력 잠금·snapshot별 초기화 정책을 함께 정합니다.
폼 라이브러리 활용
실제 프로덕션 환경에서 매우 복잡하고 큰 폼을 다룰 때는 useReducer나 직접 만든 useForm 커스텀 훅만으로도 한계에 부딪힐 수 있습니다.
특히 다음과 같은 상황에서는 “상태를 직접 들고 있을 수 있는가”보다 “필드 등록, 검증, 오류 노출, 제출 복구를 일관된 계약으로 유지할 수 있는가”를 봐야 합니다.
- 필드 수가 늘어 값, 오류, 방문 여부, dirty 상태를 따로 추적해야 한다.
- 조건부 렌더링 때문에 필드를 숨길 때 값을 유지할지 해제할지 결정해야 한다.
- 동적으로 추가/제거되는 필드 배열에서
key, 기본값, item-level error, array-level error를 분리해야 한다. - 클라이언트 스키마 검증과 서버 중복 확인 같은 비동기 검증이 함께 필요하다.
- 입력 하나가 큰 폼 전체를 다시 렌더링해 타이핑 지연이 보인다.
이러한 복잡성을 해결하기 위해 React Hook Form이나 Formik과 같은 전문적인 폼 관리 라이브러리들이 존재합니다.
이 라이브러리들은 폼 상태 관리, 유효성 검사, 제출 처리, 그리고 성능 최적화를 위한 다양한 기능을 제공합니다.
아래 다이어그램은 직접 구현한 훅에서 전문 폼 라이브러리로 넘어가야 하는 신호와 선택 기준을 정리한 것입니다.
React · Form tool boundary
직접 훅·React Hook Form·Formik의 책임 비교
폼 도구는 필드 개수보다 값·오류·방문·동적 배열·비동기 검증·렌더 범위가 함께 움직이는지로 고릅니다. 어떤 도구를 써도 클라이언트 스키마와 서버의 최종 판단 경계는 사라지지 않습니다.
| 기준 | 직접 useForm | React Hook Form | Formik |
|---|---|---|---|
| 필드 연결 | name, value, onChange 계약을 직접 작성 | register와 defaultValues, 제어형 위젯은 Controller | initialValues, values, Field 중심의 명시적 상태 |
| 검증 위치 | validate(values)와 오류 매핑을 직접 소유 | resolver로 Zod·Yup 같은 스키마 연결 | validate 또는 validationSchema |
| 동적 배열 | key, 기본값, item·array 오류 정책을 직접 설계 | useFieldArray로 추가·삭제·재정렬 | FieldArray로 배열 전이를 표현 |
| 렌더 범위 | 컴포넌트 분리와 memoization을 직접 판단 | 필요한 필드·formState 구독 범위를 좁힘 | 명시적 상태 흐름을 유지하며 독립 필드의 FastField·분할 검토 |
| 우선 선택 | 필드와 검증·제출 계약이 작고 안정적일 때 | 등록형 입력, 동적 구조, 세밀한 구독이 중요할 때 | React 상태가 드러나는 모델과 디버깅 흐름을 선호할 때 |
작고 안정적인 계약
연결: name·value·onChange를 직접 작성.
검증: validate(values)와 오류 매핑을 직접 소유.
배열: key·기본값·item/array 오류 정책을 설계.
렌더: 컴포넌트 분리와 memoization을 판단.
선택: 필드와 검증·제출 계약이 작고 안정적일 때.
등록·동적 구조·구독
연결: register·defaultValues, 제어형 위젯은 Controller.
검증: resolver로 Zod·Yup 스키마 연결.
배열: useFieldArray로 추가·삭제·재정렬.
렌더: 필요한 필드·formState 구독만 사용.
선택: 등록형 입력·동적 구조·세밀한 구독이 중요할 때.
명시적인 React 상태
연결: initialValues·values·Field 중심.
검증: validate 또는 validationSchema.
배열: FieldArray로 전이 표현.
렌더: 독립 필드에 FastField와 분할 검토.
선택: React 상태 모델과 디버깅 흐름을 선호할 때.
도구가 바뀌어도 책임 경계는 유지한다
화면 컴포넌트
열림, 접힘, 포커스처럼 표시 상태와 필드 UI를 소유합니다.
폼 로직 또는 라이브러리
values,errors,touched, dirty, 제출 전이를 일관된 모델로 관리합니다.클라이언트 스키마와 서버
스키마는 즉시 피드백 규칙을 공유하고, 권한·중복·최종 유효성은 서버가 판단합니다.
라이브러리는 필드 등록과 상태 전이를 조직하므로 내부의 controlled/uncontrolled 연결 계약을 확인합니다. 서버 오류를 필드나 폼 수준에 연결하는 방식은 앱이 정합니다.
-
React Hook Form
- 필드 등록 중심:
register와defaultValues로 네이티브 입력을 연결하고, 필요한 필드 상태만 구독해 리렌더링 범위를 줄입니다. - 제어형 컴포넌트 연결: UI 라이브러리의 날짜 선택기, 자동완성처럼 제어형 흐름이 필요한 입력은
Controller로 감쌉니다. 이 경우에는 해당 컴포넌트의 렌더 비용도 함께 봐야 합니다. - 동적 배열과 스키마 검증:
useFieldArray로 append/remove/reorder를 관리하고,resolver로 Yup, Zod 같은 스키마 유효성 검사 라이브러리와 통합합니다. - 폼 상태 분리:
formState의errors,dirtyFields,isSubmitting같은 값을 필요한 위치에서만 읽도록 설계합니다.
- 필드 등록 중심:
-
Formik
- 명시적인 상태 모델:
initialValues,values,errors,touched가 React 상태 흐름 안에 드러나므로 폼 상태를 읽고 디버깅하기 쉽습니다. - 검증 계약:
validate또는validationSchema로 검증 위치를 고정하고, 제출 오류와 필드 오류를 나눠 관리할 수 있습니다. - 컴포넌트 API:
Field,FieldArray,FastField를 활용할 수 있지만, 큰 폼에서는 갱신 범위를 의식해 컴포넌트를 나누어야 합니다.
- 명시적인 상태 모델:
이러한 라이브러리는 다음 절인 폼 유효성 검사에서 더 자세히 다룹니다.
이 절에서는 직접 훅, React Hook Form, Formik 중 무엇을 고를지 판단하기 위해 필드 등록 방식, 검증 위치, 동적 배열, 리렌더링 범위를 먼저 비교합니다.
9장 2절 복잡한 폼 상태 관리하기는 여기까지입니다.
이 장에서는 여러 입력 필드를 하나의 상태 객체로 관리하는 기본 패턴부터 시작하여, useReducer를 사용하여 상태 로직을 중앙 집중화하는 방법, 그리고 폼 관련 로직을 재사용 가능한 커스텀 훅으로 추상화하는 고급 패턴까지 살펴보았습니다.
마지막으로, 매우 복잡한 폼을 다룰 때 유용한 전문 폼 관리 라이브러리의 존재와 필요성에 대해서도 간략히 언급했습니다.