지난 이야기
사용자가 입력 필드 하나와 상호작용함에도
폼 전체가 리렌더링되어 성능이 저하하는 현상이 관찰되었다.

이에 상태 함께두기(State colocation) 기법을 적용하여
폼의 구조를 아래와 같이 리팩토링하였다.

그러나 한 가지 문제가 있었다.
폼에서 데이터를 제출하려면
각 입력 필드의 state 및 error를 반드시 알아야 하는데,
현재 구조로서는 이것이 어려워졌다는 것이다.
(리액트는 단방향 흐름이므로,
자식에서 부모 컴포넌트의 state를 전달받기는 쉽지만, 그 반대는 어렵다)
이를 해결하는 한 가지 방법은
ref를 사용하여 인풋 필드의 dom에 직접 접근하여 value를 얻는 것이다.
대표적으로, 비제어 컴포넌트 폼인
react-hook-form
라이브러리가 그렇다. 그러나 나의 경우는 이 방식을 따르기 어려웠다.
왜나하면, 각 입력 필드는 Ant Design의 컴포넌트였기 때문에
ref로 직접 접근하는 것이 불가능했기 때문이다.
대신에, 나는 그 대안으로
useImperativeHandle
훅을 사용하였다. useImperativeHandle
useImperativeHandle
은 부모 컴포넌트가 ref를 통해, 자식 컴포넌트의 상태 또는 로직에 접근할 수 있는 훅이다.
imperative(명령적인)라는 의미에서 알 수 있듯이 명령형 방식이다.
useImperativeHandle(ref, createHandle, [deps])
첫 번째 인자는 프로퍼티를 부여할 ref이고, 두 번째 인자는 객체를 리턴하는 함수다.
이 객체에 추가하고 싶은 메서드를 정의하면 된다.
이해하기가 조금 어려울 수 있다. 코드와 함께 살펴보도록 하자.
먼저 타입스크립트를 사용하므로,
부모에서 자식으로 내려보낼 ref 객체의 타입을 정의할 필요가 있다.
아래와 같이 FieldGetter라는 인터페이스를 만들었다.
이는 getFieldValue와 getFieldError라는 메서드로 구성된다.
이름에서 유추할 수 있듯이, 필드의 value 및 error 값을 가져오는 메서드이다.
맵드 타입(Mapped Type)을 사용하였다.
export type FieldValue = string | number | boolean | number[]; export type FieldError = string; export interface FieldGetter { getFieldValue: () => { [key: string]: FieldValue; }; getFieldError: () => { [key: string]: FieldError; }; }
다음으로, 부모 컴포넌트인 Form에서 ref 배열을 선언한다.
const FIELD_LENGTH = 6; const ReviewEditForm = () => { const fields = useRef<RefObject<FieldGetter>[]>( Array.from({ length: FIELD_LENGTH }, () => createRef<FieldGetter>()), ); // 중략...
위 코드가 조금 복잡할 수 있으니, 차근차근 설명하겠다.
- fields는 ref 객체이다.
- fields.current는 ref 객체들의 배열이다. 각 ref 객체는 FieldGetter이다.
- fields.current의 초기값을 선언해준다.
createRef를 사용하여, ref 객체가 FIELD_LENGTH개 담긴 배열을 생성한다.
즉, ref 객체(FieldGetter)를 6개 생성하여 배열에 담은 셈이다.
이를 각 입력 필드에 ref로 전달해준다.
아래는 입력 필드 중 하나인
TitleInput
에 props로 전달하는 코드다. <TitleInput prevTitle={prevData?.title} wasSubmitted={wasSubmitted} ref={fields.current[2]} // 자식인 인풋 컴포넌트에 ref를 넘겨준다. />
이제 자식 컴포넌트인 TitleInput을 보도록 하자.
TitleInput은 함수 컴포넌트이며, 그대로는 ref를 받을 수 없다.
forwardRef라는 고차 컴포넌트를 사용하여 래핑해주어야 한다.
그러면 두 번째 인자로 ref를 받을 수 있게 된다.
아래 두 번째 줄의 코드를 참고해주기 바란다.
const TitleInput = forwardRef( ({ prevTitle, wasSubmitted }: TitleInputProps, ref: ForwardedRef<FieldGetter>) => { const [title, setTitle] = useState(prevTitle || ''); const [touched, setTouched] = useState(false); const [error, setError] = useState(prevTitle ? MESSAGE.NO_ERROR : MESSAGE.REQUIRED_VALUE); const displayErrorMessage = (wasSubmitted || touched) && !!error; useEffect(() => { setTitle(prevTitle ? prevTitle : ''); setError(prevTitle ? MESSAGE.NO_ERROR : MESSAGE.REQUIRED_VALUE); }, [prevTitle]); const handleChange = (value: string) => { setTitle(value); validate(value); }; const validate = (value: string) => { if (!value) { setError(MESSAGE.REQUIRED_VALUE); return; } if (value.length > MAX_LENGTH) { setError(MESSAGE.EXCEEDED_MAX_LENGTH); return; } setError(MESSAGE.NO_ERROR); };
이제
useImperativeHandle
훅을 사용할 수 있다. 아래와 같이 title과 error의 값이 변화할 때마다 ref 객체를 새롭게 정의한다.
useImperativeHandle( ref, () => ({ getFieldValue: () => ({ title, }), getFieldError: () => ({ title: error, }), }), [title, error], ); return ( <> <Input id={LABEL.TITLE} placeholder="제목을 입력해주세요" showCount value={title} onChange={(e) => handleChange(e.target.value)} onBlur={() => setTouched(true)} /> <ErrorMessage message={error} visible={displayErrorMessage} testId={LABEL.TITLE} /> </> ); }, );
해당 ref 객체는 부모 컴포넌트인 Form에서 사용할 수 있다.
예컨대 아래와 같이 말이다.
// ReviewEditForm // 중략... const getFieldValues = () => { return fields.current.reduce((acc, field) => { if (field.current) { const value = field.current.getFieldValue(); Object.assign(acc, value); } return acc; }, {}); }; const getFieldErrors = () => { return fields.current.reduce((acc, field) => { if (field.current) { const error = field.current.getFieldError(); Object.assign(acc, error); } return acc; }, {}); };
getFieldValues는 각 입력 필드를 순회하면서
ref.current.getFieldValue를 사용해 value 값을 가져와 객체(acc)에 담는다.
getFieldValues가 리턴하는 이 객체는 예컨대 아래와 같은 모양이다.
{ exhibitionName: '전시회'; date: '2022-11-14'; title: '제목'; content: '내용'; isPublic: true; }
getFieldErrors도 이와 마찬가지다.
{ exhibitionName: '필수 입력값 입니다.'; date: ''; title: ''; content: '필수 입력값 입니다'; isPublic: ''; }
이제