기존 useForm 대신 useFormContext를 사용했다.
다른 컴포넌트의 버튼 클릭 시 유효성을 검사하기 위해 forwardRef와 useImperativeHandle를 사용했다.
그런데 useFormContext에 대해 알고나서 이것으로 대체하기로 했다.
useForm()
- 새로운 form 인스턴스 생성함
- <FormProvider> 없이 사용하면 자기만의 독립된 form을 가짐
useFormContext()
- 이미 FormProvider를 통해 만든 form 상태를 공유해서 사용함
- 다른 컴포넌트에서 context를 통해 접근 가능
여기서는 Message, Receiver, Sender 컴포넌트가 모두 같은 form 데이터에 접근해야 한다.
메시지 입력, 수신자 정보 확인, 송신자 입력, 주문 버튼이 모두 하나의 form 안에서 작동한다.
상위인 Order 페이지에 useForm()을 만들고 <FormProvider>로 감싼 후, 각 하위 컴포넌트는 useFormContext()로 form 기능에 접근하도록 한다.
export default function OrderPage() {
// 가장 상위에 useForm 생성
const methods = useForm();
return (
// FormProvider로 감싸기
<FormProvider {...methods}>
<form>
<Message />
<Receiver />
<OrderButton />
</form>
</FormProvider>
)
}
export default function Message() {
const { control, setValue, trigger, formState: { errors } } = useFormContext();
const [image, setImage] = useState(orderMessage[0].imageUrl);
useEffect(() => {
setValue('textMessage', orderMessage[0].defaultTextMessage)
}, [setValue]);
return (
<Wrapper>
<ImageWrapper>
{orderMessage.map((item) => (
<MessageImage
key={item.id}
src={item.thumbUrl}
alt={item.defaultTextMessage}
onClick={() => {
setImage(item.imageUrl);
setValue('textMessage', item.defaultTextMessage);
trigger('textMessage');
}}
/>
))}
</ImageWrapper>
<GifWrapper>
<GifImage src={image} alt="선택된 메시지 이미지" />
</GifWrapper>
<Controller
name="textMessage"
control={control}
rules={{ required: '메시지를 입력해주세요.' }}
render={({ field }) => (
<MessageInput
value={field.value}
onChange={field.onChange}
error={errors.textMessage}
/>
)}
/>
</Wrapper>
);
}
- useForm은 form을 만들 때, useFormContext는 form을 공유해서 사용할 때 작성함
const { control, setValue, trigger, formState: {errors } } = useFormContext();
- control : <Controller>를 통해 커스텀 컴포넌트를 폼 필드로 제어할 때 필요
- setValue(name, value): 특정 폼 필드의 값 직접 설정
- trigger(name?): 특정 필드 또는 전체 필드의 유효성 검사를 수동으로 실행함
- formState.errors: 현재 폼의 유효성 검사 에러를 담고 있는 객체
<>
<button onClick={() => {
setValue('name', 'jang');
trigger('name');
})
>
이름 자동 입력 + 유효성 검사
</button>
<Controller
name="name" // 폼 필드 이름 (key 값)
contorl={control} // useForm 또는 useFormContext에서 받은 control 객체
rules={{ required: '이름을 입력해주세요.' }} // 유효성 검사 규칙
render={({ field }) => ( // 폼 제어용 props 전달 (value, onChange 등)
<>
<input {...field} placeholder='이름' />
{errors.name && <p>{errors.name.message}</p>
</>
)}
/>
</>
<Contoller>가 필요한 이유
기본적으로 react-hook-form은 register()를 사용해 input을 폼에 연결함
<input {...register("name")} />
외부 컴포넌트, 내부적으로 ref, onChange, value를 수동으로 제어해야 하는 경우
register()만으로는 제약 발생
-> <Controller>를 써서 수동으로 연결해줌
<Controller>에 들어갈 수 있는 주요 props 정리
| 이름 | 필수 | 설명 |
| name | o | 폼 필드 이름 |
| control | o | useForm 또는 useFormContext에서 가져온 control 객체 |
| defaultValue | x | 초기 값 |
| rules | x | 유효성 검사 규칙 (required, minLength) |
| shouldUnregister | x | 컴포넌트가 언마운트될 때 필드 값을 제거할지 여부 (default: false) |
| render | o | 폼 필드를 렌더링하는 함수. 이 안에서 외부 UI 컴포넌트 사용 |
fieldState
invalid: 유효하지 않으면 true
isTouched: 포커스 후 blur 되었는지
isDirty: 값이 변경되었는지
error: 유효성 에러 정보 ({ message, type })
{ errors.email?.message } 대신 { fieldState.error?.message }도 가능
render={({ field }) => ... } 안의 field는?
react-hook-form이 관리하는 폼 상태를 해당 input에 연결하기 위해 주는 객체
주요 속성
value: 현재 입력된 값
onChange: 사용자가 입력할 때 호출됨
onBlur: 포커스 해제 시 호출됨
name: 필드 이름("name")
ref: input 요소에 연결될 ref (포커스나 DOM 제어에 필요)
- input 태그에 {...field} 하면 모든 게 자동으로 연결됨
rules={{ required: '이름을 입력해주세요.' }}
- name 필드가 비어 있으면 '이름을 입력해주세요.' 라는 에러 메시지 보여줌
{errors.name && <p>{errors.name.message}</p>}
- errors.name은 formState.errors 안에 유효성 검사 실패 시 저장되는 객체
- message는 위의 rules.required에서 지정한 메시지를 나타냄
전체 흐름
1. 사용자가 <input>에 값을 입력함
2. field.onChange()가 호출되어 react-hook-form이 값 업데이트
3. trigger() 혹은 onBlur가 호출되면 유효성 검사 진행
4. 에러가 있으면 errors.name에 메시지가 저장됨
5. 화면에는 <p> 구문 표시됨
언제 사용해야 하나
- Material UI, Chakra UI, Ant Design처럼 자체적으로 value, onChange를 갖는 input 컴포넌트를 쓸 때
- 커스텀 셀렉트, 캘린더, 체크박스 그룹 등을 사용할 때
- 기본 input이 아니고 ref 연결이 어려운 구조일 때
1. <textarea>와 연결
<Controller
name="description"
contorl={control}
rules={{ required: '설명을 입력해주세요.' }}
render={({ field }) => (
<>
<textarea {...field} placeholder='설명을 입력하세요" />
{errors.description && <p>{errors.description.message}</p>}
</>
)}
/>
- register()로 쓸 수 있지만, 커스텀 스타일 적용 등으로 <Controller>를 쓸 수도 있음
2. <select>와 연결
<Contoller
name='category'
control={control}
rules={{ required: '카테고리를 선택해주세요.' }}
render={({ field }) => (
<>
<select {...field}>
<option value=''>선택</option>
<option value='gift'>선물</option>
<option value='flower'>꽃</option>
</select>
{errors.category && <p>{errors.category.message}</p>}
</>
)}
/>
- <select> 안에 field를 넣는 이유는, select 요소를 react-hook-form이 제어하기 위함
3. <Checkbox> 여러 개 연결 (배열로 처리)
const options = ['coffee', 'cake', 'chocolate'];
<Controller
name='items'
contorl={control}
defaultValue={[]} // 기본값은 빈 배열
rules = {{
validate: value => value.length > 0 || '하나 이상 선택해주세요.'
}}
render={({ field }) => (
<>
{options.map(item => (
<label key={item}>
<input
type="checkbox"
value={item}
checked={field.value.includes(item)} // 배열 안에 포함되어있는지 확인
onChange={e => {
const newValue = e.target.checked
? [...field.value, item] // 체크된 항목 추가
: field.value.filter(v => v !== item); // 체크 해제 시 제거
field.onChange(newValue); // 폼 상태 업데이트
}}
/>
{item}
</label>
))}
{errors.items && <p>{errors.items.message}</p>}
</>
)}
/>
name="items"
- 폼 데이터 안에서 체크박스 그룹은 items라는 이름으로 저장됨
field.value.includes(item)
- 선택된 항목(field.value)이 배열이므로,
- 각 체크박스가 체크 여부 결정을 위해 includes() 사용
useFieldArray로 구현하기
const options = ['coffee', 'cake', 'chocolate'];
export default function CheckboxArrayWithFieldArray() {
const { control, handleSubmit } = useForm({
defaultValues: {
items: [] // 초기 배열
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items' // 배열 이름
});
const onSubmit = data => {
console.log(data); // 선택된 항목들
};
// items 배열에 특정 값이 있는지 확인
const isChecked = value => fields.some(field => field.value === value);
return (
<form onSubmit={handleSubmit(onSubmit)}>
{options.map(option => (
<label key={option}>
<input
type="checkbox"
value={option}
checked={isChecked(option)}
onChange={e => {
if (e.target.checked) {
append({ value: option });
} else {
const index = fields.findIndex(f => f.value === option);
if (index !== -1) remove(index);
}
}}
/>
{option}
</label>
))}
<button type="submit">제출</button>
</form>
)
}
| 항목 | Contoller 방식 | useFieldArray 방식 |
| 용도 | 외부/커스텀 input 제어 | 반복 가능 필드 배열 제어 |
| 장점 | 간결, 직관적 | 동적 추가/제거에 강함 |
| 단점 | 배열 조작 복잡 | 단순 boolean 체크박스는 과할 수 있음 |
| 예시 | 단일 값 체크박스, TextField | 수신자 목록, 질문지 항목, 체크박스 그룹 |
- 수신사 폼 추가/삭제처럼 동적 필드에서는 useFieldArray가 필수
- useFieldArray는 객체 배열로 작동하므로 [{ value: '커피' }]처럼 구성해야 함
4. 커스텀 컴포넌트
// CustomInput.tsx
export default function CustomInput({ value, onChange, error }) {
return (
<>
<input value={value} onChange={e => onChange(e.target.value)} />
{error && <p>{error}</p>}
</>
);
}
// 사용하는 곳
<Controller
name="nickname"
control={control}
rules = {{ required: '닉네임을 입력해주세요.' }}
render = {({ field }) => (
<CustomInput
value={field.value}
onChange={field.onChange}
error={errors.nickname?.message}
/>
)}
/>
- value, onChange를 field에서 꺼내서 커스텀 컴포넌트에 직접 연결해야 함
5. 외부 라이브러리 (ex: MUI TextField)
import TextField from '@mui/mateiral/TextField';
<Controller
name="email"
control={control}
rules={{
// 빈 값이면 해당 메시지 표시
required: '이메일을 입력해주세요.',
// 정규식 패턴 불일치 시 해당 메시지 표시
pattern: {
value: /^\S+@\S+$/i,
message: '유효한 이메일 형식이 아닙니다.',
}
}}
render={({ field }) => (
<TextField
{...field} // field 객체를 TextField에 그대로 전달해서 react-hook-form과 자동 연결함
label='이메일' // MUI의 라벨
error={!!errors.email} // 에러 발생 시 빨간색 테두리
helperText={errors.email?.message} // 에러 메시지 아래에 표시
/>
)}
/>
Material UI, Chakra UI, Ant Design같이 자체 상태 관리하는 컴포넌트는 반드시 Controller를 써야 함
MUI의 TextField는 내부적으로 ref, value, onChange를 직접 제어하므로,
register 대신 Controller로 연결해야 함.
error={!!errors.email}
!!는 값을 boolean으로 변환
'카테캠 > 2단계' 카테고리의 다른 글
| [7/15] 동기식 vs 비동기식 (3) | 2025.07.16 |
|---|---|
| [7/14]zod 라이브러리 (0) | 2025.07.14 |
| [7/10] 고차 함수 (0) | 2025.07.10 |
| [7/9] 중복 전화번호 검사, 모달 취소 시 되돌리기 (1) | 2025.07.09 |
| [7/8] React Hook Form 사용하기 w. useFieldArray (0) | 2025.07.09 |