react-hook-form(rhf로 중략)은 react 프로젝트에서 form을 만들 때 사용하는 대표적인 라이브러리로, 각 input의 컨트롤 과정에서 리렌더링을 최소화하고 validation이나 reset, focus 등 form에 필수적인 기능을 갖추고 있다. 이 라이브러리는 react의 제어 컴포넌트 패턴 대신, useRef와 native HTML inputs 기능에 기반한다. 반면 제어 컴포넌트는 인풋의 상태값을 state로 조절하기 때문에 두 요소 간에는 통합이 어려운데, 이 부분에 대한 해결 과정이다.
부모 컴포넌트에서 react-hook-form의 호출 및 Controller 컴포넌트 사용
rhf의 공식문에 따르면, 이 라이브러리는 기본적으로 비제어 컴포넌트를 이용하지만 제어 컴포넌트에 연결할 때에는 Controller 컴포넌트를 사용하면 된다.(https://react-hook-form.com/get-started#IntegratingControlledInputs)
//rhf에서 form 컨트롤을 위한 기본 훅
const { control, register, handleSubmit, formState, setValue, reset } =
useForm({
defaultValues: formDefaultValue,
shouldFocusError: false,
});
<Controller
name={name}
// input에 useForm으로 가져온 control을 연결
control={control}
defaultValue={false}
// render props의 함수에 인자인 field를 input에 attribute로 주입해야 한다
// field의 값은 다음과 같다.
//{name,onBlur,onChange,ref,value}
render={function ({ field }) {
return (
<Checkbox1.Wrapper id={name} {...field}>
<Checkbox1.Container>
<Checkbox1.Button />
<Checkbox1.Label>
체크 박스1
</Checkbox1.Label>
</Checkbox1.Container>
</Checkbox1.Wrapper>
);
}}
/>
여기서 중요한 건, Controller 컴포넌트가 반환하는 field의 값인 {name,onBlur,onChange,ref,value}이다. 이 값들을 Checkbox1 컴포넌트로 전달해야 한다. 특히, value라는 값이 변환되는 것을 감지해서 내부의 state와 동기화해야 한다.
문제는 기존 제어 컴포넌트가 이미 field의 값을 사용하고 있는 경우다.
예컨대, ref를 내부에서 이미 사용하고 있다면? 통합에 문제가 생긴다.
Checkbox1(제어 컴포넌트 예시)
방법은, props로 받은 value 값을 useEffect로 동기화해주는 것이다.
ref는 useImperativeHandle을 사용해서 연결해주었다.
// @ts-check
import React, {
createContext,
forwardRef,
useContext,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import '../Input.scss';
import ArrowDown from '@assets/images/ico_arr_down.svg?react';
const Context = createContext({
id: undefined,
isChecked: false,
onChange: undefined,
onClick: undefined,
});
/**
* @typedef CheckboxProps
* @property {string | undefined} id
* @property { React.ReactNode} children
* @property {boolean} [isChecked]
* @property { React.Dispatch<React.SetStateAction<boolean>>} [setIscChecked]
* @property {React.ChangeEventHandler<HTMLInputElement>} [onChange]
* @property {boolean} [value]
*/
/**
* @type {React.ForwardRefExoticComponent<CheckboxProps & React.RefAttributes<HTMLInputElement>>}
*/
const Wrapper = forwardRef(function (
{
id,
isChecked: outerIsChecked,
setIscChecked: setOuterIsChecked,
children,
onChange,
value,
...rest
},
ref,
) {
/**
* @type {React.RefObject<HTMLInputElement>}
*/
const inputRef = useRef();
useImperativeHandle(ref, () => inputRef.current, []);
const [innerIsChecked, setInnerIsChecked] = useState(
value !== undefined ? value : false,
);
const [isChecked, setIsChecked] = [
outerIsChecked !== undefined ? outerIsChecked : innerIsChecked,
setOuterIsChecked || setInnerIsChecked,
];
useEffect(() => {
if (isChecked !== value)
setIsChecked(value !== undefined ? value : false);
}, [value]);
/**
* 실제 input checkbox 이벤트
* @type {React.ChangeEventHandler<HTMLInputElement>}
*/
const handleChange = (e) => {
setIsChecked(e.target.checked);
onChange && onChange(e); // 외부에서 전달된 onChange 호출
};
/**
* 사용자용 가상 체크박스에서 실제 input 체크박스로 이벤트 전달
* @type {React.ChangeEventHandler<HTMLInputElement>}
*/
const handleClick = (e) => {
inputRef?.current && inputRef.current.click();
};
return (
<Context.Provider
value={{
id,
onChange: handleChange,
isChecked,
onClick: handleClick,
}}
>
{children}
<input
ref={inputRef}
onChange={handleChange}
type="checkbox"
id={id}
// style={{ display: 'none' }}
checked={isChecked}
{...rest}
/>
</Context.Provider>
);
});
/**
* @type {({ children, className, ...rest }:import('@/type/variables').DefaultAttributeProps)=>JSX.Element}
*/
function Container({ children, className, ...rest }) {
return (
<div className={`at_checkbox1__container ${className}`} {...rest}>
{children}
</div>
);
}
/**
* @type {({ children, className, ...rest }:import('@/type/variables').DefaultAttributeProps)=>JSX.Element}
*/
function Label({ children, className, rest }) {
const { id } = useContext(Context);
return (
<label
className={`at_checkbox1__label ${className || ''}`}
htmlFor={id}
{...rest}
>
{children}
</label>
);
}
/**
* @type {({className,rest}:import('@/type/variables').DefaultAttributeProps)=>JSX.Element}
*/
function Button({ className, ...rest }) {
const { isChecked, onClick } = useContext(Context);
return (
<button
type="button"
className={`at_checkbox1__checkbox ${isChecked ? 'checked' : ''} ${className || ''}`}
onClick={onClick}
{...rest}
>
<ArrowDown strokeWidth={2} />
</button>
);
}
const Checkbox1 = {
Wrapper,
Container,
Label,
Button,
};
export default Checkbox1;
결론
제어 컴포넌트를 만들면서 rhf같은 외부 라이브러리와 통합한 건 이번이 처음인데,
이런 경우를 대비하여 외부에서 state나 ref, 이벤트 핸들러가 주입될 때 동기화할 수 있는 방법을 고려해야겠다.