# 콜백의 참조를 유지하기 위한 커스텀 훅

리액트엔 컴포넌트의 props가 변경될 때 리렌더링이 발생한다는 규칙이 있다. props를 원시값으로 가정하면 당연히 그러는게 타당하다고 생각할 수 있지만, props가 객체나 함수인 경우엔 불필요하게 리렌더링이 발생하는 경우가 생긴다.

```typescript
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 add `useCallback` back.  
>   
> [출처](https://react.dev/reference/react/useCallback)

사용하든 안하든 어플리케이션은 제대로 동작해야 하며, 이 훅을 사용함으로써 동작하지 않던 문제가 해결되는 경우엔, 이 훅이 적절한 해결책이 아니라는 의미이다.

하지만 어떤 라이브러리에선 특정 케이스에서 참조값이 동일하지 않을 때 발생하는 버그때문에 `useMemo`를 사용해야한다고 소개하는 경우도 있다. ([참고](https://jotai.org/docs/core/atom#note-about-creating-an-atom-in-render-function))

이렇듯 리액트에선 성능 최적화가 아닌 단순히 참조값을 동일하게 유지하려는 목적때문에 `useMemo`, `useCallback`을 사용하는 경우도 분명 존재한다.

## 참조가 유지되지 않아서 생길 수 있는 문제

리액트 컴포넌트의 렌더링 로직은 순수해야 하므로 몇 번이 실행되도 문제가 없는게 정상이지만, 상태 변경이나 API 호출, 로깅 등의 순수하지 않은 로직이 존재하는 `useEffect`훅이나 이벤트 핸들러는 외부 시스템 혹은 상태에 변경을 발생시킬 수 있으므로 1번 실행되는것과 2번 실행되는것에 분명한 차이가 있다.

예를들어 intersectionObserver API를 사용하는 경우를 생각해보자. 이 API를 사용하면 관찰중인 엘리먼트가 뷰포트와 교차할 때 호출할 콜백을 등록할 수 있다. 뷰포트 안으로 들어올 때나 바깥으로 나갈 때 둘 다 호출되는데, 예시 코드는 다음과 같다.

```typescript
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`를 사용해야 한다. ([참고](https://react.dev/reference/react/useEffect#examples-custom-hooks))

```typescript
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엘리먼트가 보일 때 마다 콘솔에 텍스트가 출력된다.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1698856212224/f697ba4e-4a06-4563-ba59-c3005477a0e7.gif align="center")

여기서 `isIntersecting`이 `true`일 때 호출할 콜백을 전달받는 컴포넌트로 한단계 더 추상화가 가능하다.

```typescript
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} />
  )
}
```

이제 다음과 같이 더 선언적인 코드를 작성할 수 있다.

```typescript
const Page = () => {
  return (
    <div>
      <Item />
      <Item />
      <Item />
      <Item />
      <Item />
	  <IntersectionArea
        style={{ fontSize: 100 }}
        onIntersection={() => console.log('on intersection')}
      >
        In Area
      </IntersectionArea>
    </div>
  )
}
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1698856222559/48ce2f91-15e3-4925-9f35-66c8693abd90.gif align="center")

하지만 `useEffect`훅의 디펜던시 배열에 콜백함수가 포함되어 있어서 `isIntersecting`의 변화에 따라서만 콜백이 호출되는게 아니라, Page컴포넌트가 리렌더링되어 콜백의 참조가 바뀌어도 호출될 수 있다.

이 문제는 다음과 같은 방법으로 재현할 수 있다.

```typescript
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>
  )
}
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1698856226482/3513b60e-a531-4b95-a0c0-7a1187bfbacc.gif align="center")

이 때 `useCallback`을 사용해도 일부 문제는 해결이 가능하지만, 콜백의 디펜던시가 바뀌었을 때 이펙트가 트리거되는건 여전히 해결할 수 없다.

예를들면, 무한스크롤 UI에서 다음 페이지를 로드하면서 기준점이 되는 `cursor`값이 바뀌는 경우, 전달되는 `onIntersection`콜백의 참조는 당연히 바뀌어야하지만, 이 결과로 Effect가 트리거되는(=결과적으로 `onIntersection`이 한번 더 호출되는)건 의도하지 않았을 것이다.

```typescript
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라는 모듈](https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx)이 있는데, 처음엔 ref를 콜백으로 사용하는것에 관련된 훅인가? 했지만, 로직을 보니 useRef를 사용하여 콜백이 변경되어도 참조를 유지할 수 있는 기능을 제공하는 훅이었다.

```typescript
/**
 * 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`의 참조는 유지되기 때문에 리렌더링을 피하는 용도로 사용된다.

예시는 다음과 같다. 사용하는 쪽에서는 알 필요가 없는 훅이라, 추상화한 컴포넌트 내부에서 사용하였다.

```typescript
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>
  );
};
```

상태 업데이트에 따라 콜백의 참조가 바뀌어도 이펙트가 트리거되지 않으면서, 업데이트된 콜백과의 바인딩도 유지가 된다.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1698856246191/acaef0fc-11bf-4ef5-a6e7-58ad7397932b.gif align="center")

간단하게 해결이 되었으나, `useCallbackRef`은 만능이 아니다. 콜백이 변경됨에 따라 리렌더 혹은 이펙트가 트리거되어야 하는 상황이라면 사용하지 않아야 한다.
