복합 폼 상태 관리
여러 입력과 필드 간 의존성을 useReducer와 커스텀 훅으로 구조화하고 폼 라이브러리가 필요한 기준을 판단합니다.
이번에는 더 복잡한 폼 상태를 효과적으로 관리하는 방법을 다룹니다.
폼 필드가 많아지거나 필드 간 상호 의존성이 생기면, 단순한 useState만으로는 관리가 어려워질 수 있습니다.
이럴 때 유용한 몇 가지 패턴과 훅을 살펴보겠습니다.
React · Form complexity ladder
폼의 전이·재사용·도구를 선택하는 기준
폼 추상화는 필드 수보다 변경·검증·제출 책임의 복잡성에 맞춰 선택합니다. 현재 모델의 계약이 충분하면 유지하고, 필요한 책임을 reducer·훅·라이브러리로 분리하거나 조합합니다.
작동하는 최소 모델에서 다음 책임으로
객체 상태
name으로 key를 고르고 input 종류에 따라value또는checked를 한formData객체에 병합합니다.useReducerCHANGE_VALUE,SET_ERRORS,RESET_FORM처럼 변경 이유가 다른 전이를 한 reducer에 모읍니다.커스텀 훅
values,errors, 제출 처리와 초기화를 재사용 가능한 API로 묶어 화면 컴포넌트를 얇게 만듭니다.폼 라이브러리
동적 필드, 스키마·비동기 검증, 방문·dirty 상태와 렌더 범위가 함께 커질 때 검증된 도구를 선택합니다.
| 현재 모델 | 유지해도 되는 조건 | 다음 단계가 필요한 신호 |
|---|---|---|
| 객체 상태 | 필드가 독립적이고 한 공통 변경 함수로 충분함 | 종속 전이와 오류·초기화 규칙이 변경 함수에 섞임 |
| Reducer | 한 폼 안에서 action과 다음 상태를 읽기 쉽게 설명할 수 있음 | 여러 폼에서 같은 값·검증·제출 배선이 반복됨 |
| 커스텀 훅 | 필드 구조와 검증·제출 계약이 작고 안정적임 | 동적 배열, 비동기 검증, 세밀한 구독 최적화가 필요함 |
독립 필드와 공통 변경 함수
유지: 독립 필드와 공통 변경 함수로 충분함.
변경 신호: 종속 전이·오류·초기화 규칙이 뒤섞임.
전이 이유를 action으로
유지: 한 폼의 action과 다음 상태가 명확함.
변경 신호: 여러 폼에서 값·검증·제출 배선이 반복됨.
반복 계약을 재사용
유지: 필드 구조와 검증·제출 계약이 작고 안정적임.
변경 신호: 동적 배열·비동기 검증·세밀한 구독이 필요함.
상태 축과 동적 구조가 얽힐 때
필드 등록, 스키마, 배열, 구독 범위를 전용 모델에 맡깁니다.
객체 상태·reducer·커스텀 훅·라이브러리는 조합하거나 필요한 선택으로 바로 이동할 수 있습니다. 반복과 상태 전이의 복잡성에 맞춰 책임 경계를 정합니다.
다수의 입력 필드 관리 패턴 다시 보기
앞 절에서 잠깐 다루었지만, 여러 개의 input 필드를 하나의 useState 객체로 관리하는 패턴은 복잡한 폼 관리에 있어 첫걸음입니다.
import React, { useState } from 'react';
function MultipleInputsForm() {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
newsletter: false,
country: 'USA',
});
const handleChange = (e) => {
const { name, value, type, checked } = e.currentTarget;
// 체크박스와 같은 특정 타입의 입력은 `checked` 속성을 사용
setFormData(prevData => ({
...prevData,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('Form Submitted:', formData);
alert('폼 데이터: ' + JSON.stringify(formData, null, 2));
};
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}>
{/* 일반 텍스트 입력 */}
<div style={{ marginBottom: '15px' }}>
<label htmlFor="firstName" style={{ display: 'block', marginBottom: '5px' }}>First Name:</label>
<input id="firstName" type="text" name="firstName" value={formData.firstName} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
</div>
<div style={{ marginBottom: '15px' }}>
<label htmlFor="lastName" style={{ display: 'block', marginBottom: '5px' }}>Last Name:</label>
<input id="lastName" type="text" name="lastName" value={formData.lastName} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
</div>
<div style={{ marginBottom: '15px' }}>
<label htmlFor="email" style={{ display: 'block', marginBottom: '5px' }}>Email:</label>
<input id="email" type="email" name="email" value={formData.email} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
</div>
{/* 비밀번호 입력 */}
<div style={{ marginBottom: '15px' }}>
<label htmlFor="password" style={{ display: 'block', marginBottom: '5px' }}>Password:</label>
<input id="password" type="password" name="password" value={formData.password} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
</div>
<div style={{ marginBottom: '15px' }}>
<label htmlFor="confirmPassword" style={{ display: 'block', marginBottom: '5px' }}>Confirm Password:</label>
<input id="confirmPassword" type="password" name="confirmPassword" value={formData.confirmPassword} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
</div>
{/* 체크박스 */}
<div style={{ marginBottom: '15px' }}>
<input type="checkbox" id="newsletter" name="newsletter" checked={formData.newsletter} onChange={handleChange} style={{ marginRight: '8px' }} />
<label htmlFor="newsletter">Subscribe to Newsletter</label>
</div>
{/* Select 박스 */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor="country" style={{ display: 'block', marginBottom: '5px' }}>Country:</label>
<select id="country" name="country" value={formData.country} onChange={handleChange} style={{ width: '100%', padding: '8px' }}>
<option value="USA">United States</option>
<option value="Canada">Canada</option>
<option value="UK">United Kingdom</option>
<option value="Korea">South Korea</option>
</select>
</div>
<button type="submit" className="button" style={{ width: '100%', padding: '10px' }}>Submit</button>
</form>
</div>
);
}
export default MultipleInputsForm;이 패턴은 대부분의 경우에 잘 작동하지만, 폼 필드가 매우 많아지거나, 필드마다 복잡한 유효성 검사 로직이 필요해지면 handleChange 함수가 비대해지고, 컴포넌트 자체가 복잡해질 수 있습니다.
useReducer를 이용한 폼 상태 관리
useState 대신 useReducer 훅을 사용하면 여러 개의 상태 업데이트 로직을 한곳에 모아 관리할 수 있습니다.
이는 특히 상태 전이(state transitions)가 복잡하거나, 다음 상태가 이전 상태에 의존하는 경우에 유용합니다.
폼 상태 관리는 이러한 경우에 해당할 수 있습니다.
useReducer의 장점
- 상태 로직 중앙 집중화: 모든 상태 업데이트 로직이 리듀서 함수 내에 존재하므로, 관련 로직을 한눈에 파악하기 쉽습니다.
- 복잡한 상태 전이 관리: 여러 필드의 유효성 검사나 종속적인 필드 업데이트 등 복잡한 상태 변화를 깔끔하게 처리할 수 있습니다.
- 성능 최적화:
dispatch함수는 한 번 생성되면 변하지 않으므로, 자식 컴포넌트에dispatch를 넘겨줄 때useCallback등으로 감쌀 필요가 없어 성능 최적화에 도움이 될 수 있습니다.
import React, { useReducer } from 'react';
// 폼 상태를 관리할 리듀서 함수
function formReducer(state, action) {
switch (action.type) {
case 'CHANGE_VALUE':
return {
...state,
[action.field]: action.value,
// 필드마다 유효성 검사 로직을 리듀서 내부에 추가할 수도 있습니다.
// 예를 들어: email 필드에 대한 유효성 검사
// emailError: action.field === 'email' && !action.value.includes('@') ? '유효하지 않은 이메일' : '',
};
case 'RESET_FORM':
return action.initialState; // 초기 상태로 리셋
case 'SET_ERRORS': // 유효성 검사 오류를 설정하는 액션
return {
...state,
errors: action.errors,
};
default:
return state;
}
}
const initialFormState = {
username: '',
password: '',
comment: '',
rememberMe: false,
errors: {}, // 에러 상태도 여기에 포함
};
function ReducerForm() {
const [formState, dispatch] = useReducer(formReducer, initialFormState);
const handleChange = (e) => {
const { name, value, type, checked } = e.currentTarget;
dispatch({
type: 'CHANGE_VALUE',
field: name,
value: type === 'checkbox' ? checked : value,
});
};
const validateForm = () => {
const validationErrors = {};
if (!formState.username.trim()) {
validationErrors.username = '사용자 이름을 입력해주세요.';
}
if (formState.password.length < 6) {
validationErrors.password = '비밀번호는 6자 이상이어야 합니다.';
}
// 추가적인 유효성 검사 규칙...
return validationErrors;
};
const handleSubmit = (e) => {
e.preventDefault();
const validationErrors = validateForm();
dispatch({ type: 'SET_ERRORS', errors: validationErrors });
if (Object.keys(validationErrors).length === 0) {
const submittedValues = {
username: formState.username,
password: formState.password,
comment: formState.comment,
rememberMe: formState.rememberMe,
};
console.log('폼 제출됨 (useReducer):', submittedValues);
alert('폼 데이터: ' + JSON.stringify(submittedValues, null, 2));
dispatch({ type: 'RESET_FORM', initialState: initialFormState }); // 제출 후 폼 리셋
} else {
console.log('유효성 검사 실패:', validationErrors);
alert('폼 입력값을 확인해주세요.');
}
};
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' }}>`useReducer`를 이용한 폼</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '15px' }}>
<label htmlFor="reducer-username" style={{ display: 'block', marginBottom: '5px' }}>사용자 이름:</label>
<input id="reducer-username" type="text" name="username" value={formState.username} onChange={handleChange} aria-invalid={Boolean(formState.errors.username)} aria-describedby={formState.errors.username ? 'reducer-username-error' : undefined} style={{ width: '100%', padding: '8px', border: formState.errors.username ? '1px solid red' : '1px solid #ccc' }} />
{formState.errors.username && <p id="reducer-username-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{formState.errors.username}</p>}
</div>
<div style={{ marginBottom: '15px' }}>
<label htmlFor="reducer-password" style={{ display: 'block', marginBottom: '5px' }}>비밀번호:</label>
<input id="reducer-password" type="password" name="password" value={formState.password} onChange={handleChange} aria-invalid={Boolean(formState.errors.password)} aria-describedby={formState.errors.password ? 'reducer-password-error' : undefined} style={{ width: '100%', padding: '8px', border: formState.errors.password ? '1px solid red' : '1px solid #ccc' }} />
{formState.errors.password && <p id="reducer-password-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{formState.errors.password}</p>}
</div>
<div style={{ marginBottom: '15px' }}>
<label htmlFor="reducer-comment" style={{ display: 'block', marginBottom: '5px' }}>코멘트:</label>
<textarea id="reducer-comment" name="comment" value={formState.comment} onChange={handleChange} rows="4" style={{ width: '100%', padding: '8px', border: '1px solid #ccc' }} />
</div>
<div style={{ marginBottom: '20px' }}>
<input type="checkbox" id="rememberMe" name="rememberMe" checked={formState.rememberMe} onChange={handleChange} style={{ marginRight: '8px' }} />
<label htmlFor="rememberMe">로그인 정보 저장</label>
</div>
<button type="submit" className="button" style={{ width: '100%', padding: '10px' }}>Submit</button>
</form>
</div>
);
}
export default ReducerForm;useReducer를 사용하면 폼의 상태 변화 로직과 유효성 검사 로직을 리듀서 함수 내에 통합하여 관리할 수 있습니다.
이는 폼이 커질수록 코드의 응집성을 높여줍니다.