June

react의 ref 객체를 업데이트 의존성으로 넣어줘야 할 때

react의 ref는 dom을 가리키는 reference나 렌더링과 상관 없는 객체, 상태를 저장하기 위해서 사용합니다.

렌더링과 무관하기 때문에 ref.current로 가리키고 있는 데이터가 업데이트 되더라도 re-render가 발생하지 않으며, useEffect의 의존성 배열에 ref.current 값을 넣어놓았더라도 업데이트가 항상 일어나진 않습니다.

물론 side effect로 re-render가 발생하고 ref.current가 의존성으로 들어가있는 로직에선 해당 값이 업데이트가 잘 된 모습을 볼 수 있습니다.

ref에 값을 넣은 예시

우선 ref를 dom을 가리키는 용도가 아닌 값을 저장하는 용도로 사용해보겠습니다.

import { useEffect, useRef } from "react";

const Example = () => {
  const ref = useRef(0);

  useEffect(() => {
    // ref.current에 값이 들어오고 업데이트 발생하면 호출됨
    // useEffect 특성상 첫 렌더 시에도 호출되지만, 첫 렌더때는 ref 값이 0이어서 스킵됨
    if (ref.current) {
      console.log(`!!! Update ref !!! => ${ref.current}`);
    }
  }, [ref.current]);

  return (
    <div>
      <button onClick={() => {
        ref.current = ref.current + 1;
      }}>
        테스트
      </button>
    </div>
  );
};

export default Example;

위 예시에 대한 링크입니다.

테스트라는 버튼을 아무리 눌러도 콘솔에는 아무것도 찍히지 않습니다. ref.current는 계속 업데이트 되고 있지만, re-render가 발생하지 않아 보이지 않는것이죠.

이번엔 코드를 한번 수정해보겠습니다.

import { useEffect, useRef, useState } from "react";

const Example = () => {
  const [count, setCount] = useState(0);
  const ref = useRef(0);

  useEffect(() => {
    // ref.current에 값이 들어오고 업데이트 발생하면 호출됨
    // useEffect 특성상 첫 렌더 시에도 호출되지만, 첫 렌더때는 ref 값이 0이어서 스킵됨
    if (ref.current) {
      console.log(`!!! Update ref !!! => ${ref.current}`);
    }
  }, [ref.current]);

  return (
    <div>
      <button onClick={() => {
        ref.current = count + 1;
        setCount(count + 1);
      }}>
        테스트
      </button>
    </div>
  );
};

export default Example;

위 예시에 대한 링크입니다.

테스트 버튼을 클릭할 때마다 re-render를 위해 count를 업데이트 해줬습니다. re-render가 발생하며 useEffect 로직을 지나가는데, ref.current의 값이 이전 렌더와 달라졌기 때문에 버튼을 클릭할 때 마다 !!! Update ref !!! => count 로그가 지속적으로 찍히게 됩니다.

ref에 dom을 넣은 예시1

이번엔 ref를 dom에 할당해보겠습니다.

import { useEffect, useRef, useState } from "react";

const Example = () => {
  const [count, setCount] = useState(0);
  const ref = useRef();

  useEffect(() => {
    console.log("Update Count", ref.current, count);
    setCount(count + 1);
  }, [ref.current]);

  console.log("render", ref.current, count);

  return (
    <div ref={ref}>
      <div>카운트: {count}</div>
      <button
        onClick={() => {
          ref.current = null;
        }}
      >
        버튼
      </button>
    </div>
  );
};

export default Example;

위 예시에 대한 링크입니다.

콘솔로 로그를 찍어줬는데, 이는 ref의 할당을 보기 위함입니다. 콘솔의 결과는 아래처럼 나옵니다.

// 순서대로 ["render" or "Update Count" | dom | count]
render undefined 0
Update Count <dom> 0
render <dom> 1
Update Count <dom> 1
render <dom> 2

첫 렌더 시 아직 할당이 없던 dom에 할당이 이루어지게 됩니다. 이후 useEffect를 통해 콘솔이 찍힐 때와 다시 렌더될 때의 콘솔에는 ref.current에 dom이 할당되어 있게 됩니다.

의존성이 ref.current에 밖에 없는데, count의 업데이트로 인해 한번 더 useEffect가 실행된다? 이는 react가 dev 모드일 때만 발생하며, StrictMode 설정이 되어있을 경우에 실행됩니다. (codesandbox에서 index.js의 StrictMode를 제외해도 발생하는데, 이는 가시적으로 설정하지 않은 react config에 설정되어있기 때문일 수 있습니다.)

화면에는 카운트: 2 이라는 텍스트와 버튼 이 노출되어 있는데요, 버튼을 클릭하면 ref.current에 null을 할당하도록 해놨습니다. 클릭하면 어떻게 될까요?

위에서 설명했듯이 ref의 업데이트는 추적하지 않기 때문에 아무런 변화가 나타나지 않습니다. 그러면 클릭할 때 상태를 한번 업데이트하도록 해보겠습니다.

...
  <button
    onClick={() => {
      ref.current = null;
      setCount(count + 1); // 이 한줄만 추가!
    }}
  >
    버튼
  </button>
...

상태 업데이트까지 추가된 코드에 대한 링크입니다.

setCount 를 추가하고 버튼을 다시 클릭하면 아래와 같은 로그가 출력됩니다.

// 순서대로 ["render" or "Update Count" | dom | count]
render null 0
Update Count null 0
render null 1

ref.current가 null이 된것을 확인할 수 있습니다. ref.current가 업데이트 되었으므로 useEffect가 실행되지만, 계속 클릭하면 ref.current는 계속 null로 변화가 없으므로 useEffect는 1번만 실행됩니다.

ref에 dom을 넣은 예시2

다른 예시를 하나 더 알아보겠습니다.

import { useEffect, useRef, useState } from "react";

const Example = () => {
  const [count, setCount] = useState(0);
  const [isShowCount, setIsShowCount] = useState(false);
  const ref = useRef();

  useEffect(() => {
    setTimeout(() => {
      setIsShowCount(true);
    }, 1000);
  }, []);

  useEffect(() => {
    if (ref.current) {
      console.log("Update Count", ref.current, count);
      setCount(count + 1);
    }
  }, [ref.current]);

  console.log("render", ref.current, count);

  return isShowCount ? (
    <div ref={ref}>
      <div>카운트: {count}</div>
    </div>
  ) : null;
};

export default Example;

위 예시에 대한 링크입니다.

이번엔 컴포넌트 영역을 모두 가리고, setTimeout 1초를 지나면 카운트가 노출되도록 해놨습니다. 로그에는 render undefined 0이 두번 뜨게됩니다. useEffect 안에서는 ref.current가 최초 렌더링 시 없었고, ref.current를 통해 업데이트를 확인할 수 없기 때문에 그대로 동작이 끝나게 됩니다.

이번에도 코드를 바꿔볼텐데, 위에 알아본 ref에 값을 넣은 예시, ref에 dom을 넣은 예시1과는 다르게 어느정도 해결책이 있습니다.

import { useCallback, useEffect, useState } from "react";

const Example = () => {
  const [count, setCount] = useState(0);
  const [isShowCount, setIsShowCount] = useState(false);

  useEffect(() => {
    setTimeout(() => {
      setIsShowCount(true);
    }, 1000);
  }, []);

  const ref = useCallback((dom) => {
    if (dom) {
      console.log("Update Count", dom, count);
      setCount(count + 1);
    }
  }, []);

  console.log("render", ref.current, count);

  return isShowCount ? (
    <div ref={ref}>
      <div>카운트: {count}</div>
    </div>
  ) : null;
};

export default Example;

위 예시에 대한 링크입니다.

ref가 useCallback 함수로 변경되었습니다. 어라...? ref안에 함수가 어떻게 들어가나요..?

  ...
  // Bivariance hack for consistent unsoundness with RefObject
  type RefCallback<T> = { bivarianceHack(instance: T | null): void }["bivarianceHack"];
  type Ref<T> = RefCallback<T> | RefObject<T> | null;
  type LegacyRef<T> = string | Ref<T>;
  ...

dom의 ref 타입은 LegacyRef를 가리킵니다. LegacyRefstring | Ref<T>를 가리키고, RefRefCallback<T> | RefObject<T> | null 를 가리킵니다.

결론적으로 RefCallback 타입을 통해 callback 함수를 넣어도 괜찮다는 결론이 나옵니다.

주의해야 할 점은 ref에 들어갈 callback 함수가 업데이트 되면 렌더 되면서 다시 호출되게 됩니다. 예시에서는 setCount을 통해 Maximum call exceed 에러가 발생할 수 있으므로 의존성 없는 useCallback으로 감싸줍니다.

일반 함수를 useCallback으로 감싸지 않으면 re-render마다 업데이트 됩니다. 여기서 RefCallback은 일반 함수와 동일하다고 생각해주세요. 주석과 bivarianceHack 에 대해선 다음 포스트로 엮어보겠습니다.

렌더가 이루어지면서 dom의 ref가 할당된 뒤, useCallback의 프로퍼티로 dom node 인자가 들어오게 됩니다. 이제 기존에 ref.current를 가지고 useEffect에서 실행했던 비즈니스 로직을 dom node 인자를 가지고 진행하면 됩니다.


ref에 값을 넣은 예시, ref에 dom을 넣은 예시1, ref에 dom을 넣은 예시2 총 3가지 예시를 봤습니다. 이를 통해 아래 내용들을 알 수 있었습니다.

  • dom의 ref 속성에 ref object를 담을 경우, ref에 값을 넣는 경우 모두 ref 업데이트를 추적할 순 없다.
  • ref에는 callback 함수를 대신 넣을 수 있으며 callback 함수가 업데이트 되고 렌더가 될 때 호출된다.

여러 예시에서 ref의 강제 업데이트를 위해 setCount를 호출해 봤는데 ref에 값을 넣은 예시, ref에 dom을 넣은 예시1과 같은 케이스에 정말 꼭 필요하다면 state로 사용하고 업데이트를 미루는 방식을 지향해야 합니다. 그리고 ref의 강제 업데이트를 위해 re-render를 만들어내면 안됩니다. 하나의 업데이트라도 서비스에 영향 없이 줄여서 성능을 올려야 하는데 일부로 업데이트를 만들 수는 없으니까요.

결론적으로 ref는 dom을 가리키는게 아니라면 최대한 re-render와 관련 없는 데이터를 저장해놓는 용도로만 조심히 써야 한다는 것입니다.