본문 바로가기
카테캠/2단계

[7/10] useFormContext

by 쪼꼬에몽 2025. 7. 14.

기존 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으로 변환