콜백의 참조를 유지하기 위한 커스텀 훅
Table of contents
리액트엔 컴포넌트의 props가 변경될 때 리렌더링이 발생한다는 규칙이 있다. props를 원시값으로 가정하면 당연히 그러는게 타당하다고 생각할 수 있지만, props가 객체나 함수인 경우엔 불필요하게 리렌더링이 발생하는 경우가 생긴다.
const Component = (props) => {
const callback = () => {};
const object = { a: 1 };
return (
<Child callback={callback} object={object} />
)
}
예를들어 위 예제의 경우, 어떤 이유에서든 Component
가 리렌더링 되었다면, 컴포넌트의 Body가 다시 실행되므로 callback
과 object
는 다시 평가된다. 내용이 바뀌지는 않았는데, 참조값이 바뀌니까 Child
역시 리렌더링된다.
대부분의 경우엔 이런 상황에 리렌더링이 한번 더 되어도 성능 문제는 발생하지 않으며, 발생한다고 해도 useCallback
이나 useMemo
를 통해 참조를 유지하는 방법을 제공하니 문제없이 넘어갈 수 있다.
단, useCallback
이나 useMemo
는 기본적으로 성능 최적화를 위해서만 사용해야 한다.
You should only rely on
useCallback
as a performance optimization. If your code doesn’t work without it, find the underlying problem and fix it first. Then you may adduseCallback
back.
사용하든 안하든 어플리케이션은 제대로 동작해야 하며, 이 훅을 사용함으로써 동작하지 않던 문제가 해결되는 경우엔, 이 훅이 적절한 해결책이 아니라는 의미이다.
하지만 어떤 라이브러리에선 특정 케이스에서 참조값이 동일하지 않을 때 발생하는 버그때문에 useMemo
를 사용해야한다고 소개하는 경우도 있다. (참고)
이렇듯 리액트에선 성능 최적화가 아닌 단순히 참조값을 동일하게 유지하려는 목적때문에 useMemo
, useCallback
을 사용하는 경우도 분명 존재한다.
참조가 유지되지 않아서 생길 수 있는 문제
리액트 컴포넌트의 렌더링 로직은 순수해야 하므로 몇 번이 실행되도 문제가 없는게 정상이지만, 상태 변경이나 API 호출, 로깅 등의 순수하지 않은 로직이 존재하는 useEffect
훅이나 이벤트 핸들러는 외부 시스템 혹은 상태에 변경을 발생시킬 수 있으므로 1번 실행되는것과 2번 실행되는것에 분명한 차이가 있다.
예를들어 intersectionObserver API를 사용하는 경우를 생각해보자. 이 API를 사용하면 관찰중인 엘리먼트가 뷰포트와 교차할 때 호출할 콜백을 등록할 수 있다. 뷰포트 안으로 들어올 때나 바깥으로 나갈 때 둘 다 호출되는데, 예시 코드는 다음과 같다.
const div = document.querySelector('div');
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
console.log(entry.isIntersecting);
});
observer.observe(div);
useEffect
훅은 기본적으로 리액트의 외부 시스템과의 동기화를 위해서 사용하는 훅인데, 위 코드에서 관찰중인 요소가 뷰포트 내로 들어왔는지, 바깥으로 나갔는지에 대한 상태(entry.isIntersecting
)는 리액트가 아닌, 리액트 외부 시스템인 Web API가 관리한다.
그래서 리액트가 관리하는 상태와 동기화를 하려면 useEffect
를 사용해야 한다. (참고)
const useIntersectionObserver = (ref) => {
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const element = ref.current;
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
setIsIntersecting(entry.isIntersecting);
});
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return isIntersecting;
}
const Component = () => {
const ref = useRef(null);
const isIntersecting = useIntersectionObserver(ref);
useEffect(() => {
if (isIntersecting) console.log('on intersection');
}, [isIntersecting])
return (
<div>
<Item />
<Item />
<Item />
<Item />
<Item />
<div ref={ref}>Target</div>
</div>
)
}
Div엘리먼트가 보일 때 마다 콘솔에 텍스트가 출력된다.
여기서 isIntersecting
이 true
일 때 호출할 콜백을 전달받는 컴포넌트로 한단계 더 추상화가 가능하다.
interface IntersectionAreaProps extends ComponentPropsWithoutRef<'div'> {
children?: ReactNode;
onIntersection?: () => void;
}
const IntersectionArea = (props: IntersectionAreaProps) => {
const { onIntersection, ...restProps } = props;
const ref = useRef(null);
const isIntersecting = useIntersectingObserver(ref);
useEffect(() => {
if (isIntersecting) onIntersection?.();
}, [isIntersecting, onIntersection])
return (
<div {...restProps} />
)
}
이제 다음과 같이 더 선언적인 코드를 작성할 수 있다.
const Page = () => {
return (
<div>
<Item />
<Item />
<Item />
<Item />
<Item />
<IntersectionArea
style={{ fontSize: 100 }}
onIntersection={() => console.log('on intersection')}
>
In Area
</IntersectionArea>
</div>
)
}
하지만 useEffect
훅의 디펜던시 배열에 콜백함수가 포함되어 있어서 isIntersecting
의 변화에 따라서만 콜백이 호출되는게 아니라, Page컴포넌트가 리렌더링되어 콜백의 참조가 바뀌어도 호출될 수 있다.
이 문제는 다음과 같은 방법으로 재현할 수 있다.
const Page = () => {
const [count, setCount] = useState(1);
return (
<div>
<Item />
<Item />
<Item />
<IntersectionArea
style={{ fontSize: 100 }}
onIntersection={() => console.log('on intersection')}
>
In Area
</IntersectionArea>
<button type="button" onClick={() => setCount(p => p + 1)}>
Increase Count
</button>
<div>{count}</div>
<Item />
<Item />
<Item />
</div>
)
}
이 때 useCallback
을 사용해도 일부 문제는 해결이 가능하지만, 콜백의 디펜던시가 바뀌었을 때 이펙트가 트리거되는건 여전히 해결할 수 없다.
예를들면, 무한스크롤 UI에서 다음 페이지를 로드하면서 기준점이 되는 cursor
값이 바뀌는 경우, 전달되는 onIntersection
콜백의 참조는 당연히 바뀌어야하지만, 이 결과로 Effect가 트리거되는(=결과적으로 onIntersection
이 한번 더 호출되는)건 의도하지 않았을 것이다.
const { items, nextCursor } = useItems();
// cursor가 바뀌면 메모이제이션된 loadMore도 바뀌어야 한다.
const loadMore = useCallback(() => {
getItems({ cursor: nextCursor });
}, [nextCursor]);
return (
<div>
<Items items={items} />
<IntersectionArea onIntersection={loadMore} />
</div>
)
위 코드를 보면, useCallback
을 사용했는데도 다음 페이지를 불러왔을 때, nextCursor
값이 바뀌니까, loadMore
의 참조도 바뀌어서 IntersectionArea
의 이펙트가 트리거되고, onIntersection
이 다시 호출된다.
use-callback-ref로 참조 유지하기
radix-ui라는 headless 컴포넌트를 제공하는 라이브러리가 있다. 궁금해서 가끔 코드를 살펴보는데, 해당 라이브러리에서 사용되는 내부 유틸리티중에 쓸만한게 정말 많다고 생각한다.
그중에 use-callback-ref라는 모듈이 있는데, 처음엔 ref를 콜백으로 사용하는것에 관련된 훅인가? 했지만, 로직을 보니 useRef를 사용하여 콜백이 변경되어도 참조를 유지할 수 있는 기능을 제공하는 훅이었다.
/**
* A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
* prop or avoid re-executing effects when passed as a dependency
*/
function useCallbackRef<T extends (...args: any[]) => any>(callback: T | undefined): T {
const callbackRef = React.useRef(callback);
React.useEffect(() => {
callbackRef.current = callback;
});
// <https://github.com/facebook/react/issues/19240>
return React.useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
}
전달받은 함수를 callbackRef.current
에 매 렌더링마다 동기화시킨다. 콜백이 prop이나 디펜던시 배열로 전달될 때, 실제로는 동작이 달라져서 참조가 바뀐다고 해도, callbackRef
의 참조는 유지되기 때문에 리렌더링을 피하는 용도로 사용된다.
예시는 다음과 같다. 사용하는 쪽에서는 알 필요가 없는 훅이라, 추상화한 컴포넌트 내부에서 사용하였다.
const IntersectionArea = (props: IntersectionAreaProps) => {
const { onIntersection, ...restProps } = props;
const ref = useRef(null);
const isIntersecting = useIntersectingObserver(ref);
const memoizedOnIntersection = useCallbackRef(onIntersection);
useEffect(() => {
if (isIntersecting) memoizedOnIntersection?.();
}, [isIntersecting, memoizedOnIntersection])
return (
<div {...restProps} />
)
}
const Page = () => {
const [count, setCount] = useState(1);
return (
<div style={{ textAlign: 'center' }}>
<Item />
<Item />
<Item />
<IntersectionArea
style={{ fontSize: 100 }}
onIntersection={() => console.log(count)}
>
In Area
</IntersectionArea>
<button type="button" onClick={() => setCount((p) => p + 1)}>
Increase Count
</button>
<div>{count}</div>
<Item />
<Item />
<Item />
</div>
);
};
상태 업데이트에 따라 콜백의 참조가 바뀌어도 이펙트가 트리거되지 않으면서, 업데이트된 콜백과의 바인딩도 유지가 된다.
간단하게 해결이 되었으나, useCallbackRef
은 만능이 아니다. 콜백이 변경됨에 따라 리렌더 혹은 이펙트가 트리거되어야 하는 상황이라면 사용하지 않아야 한다.