useImperativeHandle

부모 컴포넌트에서 자식 컴포넌트 HTML 요소를 제어하려면 ref를 prop(forwardRef)으로 자식 컴포넌트에게 넘겨주어야 합니다.

type MyInputProps = ComponentPropsWithoutRef<'input'>;
type MyInputRef = HTMLInputElement;
 
const MyInput = forwardRef<MyInputRef, MyInputProps>(
  function MyInput(props, ref) {
    return <input css={{ border: '1px solid black' }} {...props} ref={ref} />;
  },
);
 
export default function ParentComponent() {
  const ref = useRef<HTMLInputElement>(null);
 
  const handleClick = () => {
    console.log(ref.current); // <input/>
  };
 
  return (
    <div>
      <div
        css={{ display: 'flex', flexDirection: 'column', maxWidth: '400px' }}
      >
        <MyInput ref={ref} />
        <button css={{ border: '1px solid black' }} onClick={handleClick}>
          focus button
        </button>
      </div>
    </div>
  );
}

기본적인 사용 방법

부모 컴포넌트에서 버튼을 클릭하면 handleClick 이벤트 함수가 실행되어 콘솔 로그에는 <input/> 요소가 찍히게 됩니다.
해당 요소 대신 사용자 지정 메서드를 노출시켜 로직을 커스텀할 수 있는데, 이때 사용하는 훅이 바로 useImperativeHandle 입니다. 자식 요소에서 useImperativeHandle를 사용하여 전달받은 ref를 첫 번째 인자로 넣으면 버튼을 클릭했을 때 이전과 다르게 빈 객체가 찍히게 됩니다.

const MyInput = forwardRef<MyInputRef, MyInputProps>(
  function MyInput(props, ref) {
    useImperativeHandle(ref, () => {
      return {};
    }, []);
    // ...
  },
);
 
export default function ParentComponent() {
  const ref = useRef<HTMLInputElement>(null);
 
  const handleClick = () => {
    console.log(ref.current); // {}
  };
  // ...
}

이제 useImperativeHandle 활용하여 focus 메서드 만을 노출시키고자 합니다. MyInputRef의 타입을 다음과 같이 변경하여 자식과 부모 모두 ref 타입을 변경해 주었습니다.

type MyInputRef = {
  focus: () => void;
};
const MyInput = forwardRef<MyInputRef, MyInputProps>(function MyInput(
  // ...
))
export default function ParentComponent() {
  const ref = useRef<MyInputRef>(null);
  // ...
}

ref 참조는 다음과 같이 focus만을 추론하게 됩니다.

다음으로는 자식 컴포넌트에서 useImperativeHandle 콜백 함수의 반환 값을 수정하고, ref를 새로 추가하여 참조하고자 하는 dom에 연결해 주어야 합니다.

const MyInput = forwardRef<MyInputRef, MyInputProps>(
  function MyInput(props, ref) {
    const inputRef = useRef<HTMLInputElement>(null);
    useImperativeHandle(ref, () => {
      return {
        focus() {
          inputRef.current?.focus();
        },
      };
    }, []);
    return (
      <input css={{ border: '1px solid black' }} {...props} ref={inputRef} />
    );
  },
);

이제 input 요소의 권한이 없어지고, focus 메서드를 호출할 수 있습니다.

그렇다면 useImperativeHandle 언제 활용할 수 있을까요?

부모가 자식 컴포넌트의 두 개 이상의 DOM을 조작하는 경우 유용하게 사용할 수 있습니다. focusAndScroll와 같이 메서드 이름은 DOM 메서드와 일치할 필요가 없습니다. 또한 자식 컴포넌트는 내부 상태나 로직을 관리하면서 부모와 결합을 낮출 수 있습니다.

type MyInputProps = ComponentPropsWithoutRef<'input'>;
type MyInputRef = {
  focusAndScroll: () => void;
};
 
const MyInput = forwardRef<MyInputRef, MyInputProps>(
  function MyInput(props, ref) {
    const divRef = useRef<HTMLDivElement>(null);
    const inputRef = useRef<HTMLInputElement>(null);
    useImperativeHandle(ref, () => {
      return {
        focusAndScroll() {
          inputRef.current?.focus();
          const node = divRef.current;
          if (node) {
            node.scrollTop = node?.scrollHeight;
          }
        },
      };
    }, []);
    return (
      <>
        <div css={{ overflowY: 'scroll', height: '100px' }} ref={divRef}>
          {Array(18)
            .fill('Comment')
            .map((content, i) => (
              <div key={content}>comment {i}</div>
            ))}
        </div>
        <input css={{ border: '1px solid black' }} {...props} ref={inputRef} />
      </>
    );
  },
);
 
export default function ParentComponent() {
  const ref = useRef<MyInputRef>(null);
 
  const handleClick = () => {
    ref.current?.focusAndScroll();
  };
 
  return (
    <div>
      <div
        css={{ display: 'flex', flexDirection: 'column', maxWidth: '400px' }}
      >
        <MyInput ref={ref} />
        <button css={{ border: '1px solid black' }} onClick={handleClick}>
          focus button
        </button>
      </div>
    </div>
  );
}

ref를 과도하게 사용하지 마세요.

공식 문서에서는 ref를 props로 표현할 수 없는 필수적인 행동(특정 노드로 스크롤 하기, 노드에 초점 맞추기, 애니메이션 촉발하기, 텍스트 선택하기 등)에서만 사용해야 한다고 합니다.

참조