동일한 dom element를 제어하고 싶어하는 여러 개의 ref를 잘 통합하는 방법에 대한 아티클입니다.
TextArea
라는 이름의 공통 컴포넌트가 있습니다. 이 컴포넌트는 일반적인 textarea
요소와 거의 동일한 동작을 하지만 두 개의 특별한 요구사항이 있습니다.
fowardRef
를 통해 외부 주입된 ref를 지원한다.1번 요구사항을 만족 시키기 위해 innerRef라는 이름의 ref를 생성해서 텍스트 양에 따라 동적으로 높이를 조절하도록 했습니다.
2번 요구사항 역시 만족 시키기 위해 컴포넌트를 forwardRef
로 감싸고 forwardedRef
를 인자로 받았습니다.
두 개의 ref를 이제 textarea에 연결 시켜 주기만 하면 됩니다. 하지만 아쉽게도 dom element는 ref를 하나 밖에 받지 못합니다.
처음 떠올린 방법은 forwardedRef
가 있으면 해당 ref를 사용하고, 없다면 TextArea
컴포넌트 내에서 선언한 innerRef
를 사용하는 방식입니다.
const ref = forwardedRef ?? innerRef;
처음에는 깔끔한 방법이라 생각되었지만 아래 ref.current를 사용한 부분에서 에러가 발생했습니다.
ref.current
를 사용한 경우 발생하는 에러 메시지Property 'current' does not exist on type '((instance: HTMLTextAreaElement | null) => void) | MutableRefObject<HTMLTextAreaElement | null>'.
Property 'current' does not exist on type '(instance: HTMLTextAreaElement | null) => void'.ts(2339)
에러는 forwardedRef
가 CabllackRef
인 경우가 고려되지 않아서 발생한 것이었습니다.
type Ref<T> = RefCallback<T> | RefObject<T> | null;
React
의 useImerpativeHandle
api는 포워딩된 ref의 동작을 명령적으로 선언할 때 사용됩니다. 사용법은 아래와 같습니다.
useImperativeHandle(forwardedRef, () => ({
focus: () => innerRef.current?.focus(),
get scrollHeight() {
return innerRef.current?.scrollHeight;
},
}));
forwardedRef
을 통해 수행하고 싶은 동작을 미리 선언하고 노출하기 때문에 안정적인 방식으로 보입니다. 하지만 forwardedRef
의 동작을 일일이 지정 해주어야 하기 때문에, dom element의 모든 native method, property를 그대로 포워딩 시켜주는 것은 매우 번거로워 보입니다.
결국 여러개의 ref를 병합해서 하나의 dom element에 달아줄 수 있다면 가장 깔끔한 해답이 될 것 같습니다.
CallbackRef
를 활용하면 여러 개의 ref를 병합할 수 있습니다.
const mergeRefs = (node: HTMLTextAreaElement | null) => {
if (typeof forwardedRef === 'function') {
forwardedRef(node); // Callback Ref 처리
} else if (forwardedRef) {
(forwardedRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = node; // Object Ref 처리
}
innerRef.current = node; // 내부 Ref 설정
};
return <textarea ref={mergeRef()} />
이제 innerRef
와 forwardedRef
를 모두 textarea에 연결해 요구사항을 만족할 수 있게 되었습니다!
더 나아가 다양한 형태의 ref를 여러 개 병합할 수 있도록 개선해보겠습니다.
mergeRefs
함수는 여러 개의 다양한 ref를 인자로 받아 dom에 등록 시킵니다.
import type { MutableRefObject, Ref, RefCallback } from 'react';
export const mergeRefs = <T>(
...inputRefs: (Ref<T> | undefined)[]
): Ref<T> | RefCallback<T> => {
const notNilRefs = inputRefs.filter((inputRef) => !!inputRef);
return (node) => {
notNilRefs.forEach((inputRef) => {
if (typeof inputRef === 'function') inputRef(node);
else (inputRef as MutableRefObject<T | null>).current = node;
});
};
};
<textarea ref={mergeRefs(forwardedRef, innerRef)} />