DOM과 ref

WooBuntu·2021년 3월 28일
0

알고 쓰자 리액트

목록 보기
5/11

https://ko.reactjs.org/docs/refs-and-the-dom.html

Ref를 사용해야 할 때

일반적으로 리액트에서 부모 컴포넌트는 자식 컴포넌트와 props로 상호작용한다. 자식을 수정하려면 새로운 props를 전달하여 자식을 재렌더링해야 한다는 의미이다. 하지만 props를 통하지 않고 직접적으로 자식을 수정해야 하는 경우도 있는데, 바로 아래와 같은 경우들이다.

  • 포커스, 텍스트 선택영역, 미디어 재생 등을 관리할 때

  • 애니메이션을 직접적으로 실행시킬 때

  • 서드 파티 DOM라이브러리를 리액트와 함께 사용할 때

선언적으로? 해결할 수 있는 문제에서는 ref사용을 지양하고, prop의 사용을 권장한다고 한다.
(즉, props로 해결이 안 되는 경우에 한해 ref를 사용하라는 것)

useRef

const refContainer = useRef(initialValue);

useRef는 인자로 전달된 initialValue를 current 프로퍼티로 가지는 객체를 반환하며, 이 객체는 컴포넌트의 Lifecycle 내내 참조를 유지한다. current프로퍼티는 가변값으로 이 객체를 DOM의 ref속성에 전달하면, 해당 DOM이 갱신될 때마다 current프로퍼티는 갱신된 DOM을 참조하게 된다. 하지만 참조값이 바뀌더라도 재렌더링은 발생하지 않기 때문에, 만약 특정 DOM에 ref를 attach 또는 detach할 때 특정 코드를 실행하고 싶다면 callback ref를 사용하는 것이 낫다.

일반적으로는 다음과 같이 자식 컴포넌트에 '명령적'으로 접근하는 목적으로 사용한다.(props를 통해 상호작용하는 것이 '선언적'인 방식인 모양이다)

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

부모 컴포넌트에게 DOM ref 공개하기

부모 컴포넌트에서 자식 컴포넌트의 DOM노드에 접근하는 것은 컴포넌트의 캡슐화를 파괴하기 때문에 그다지 권장되지는 않는다.

그래도 자식 컴포넌트의 DOM노드를 focus하거나, 크기와 위치를 계산하는 등의 일을 해야할 때는 ref forwarding을 사용할 수 있다.

React.forwardRef

인자로 전달받은 ref속성을 하부 트리 내의 다른 컴포넌트로 전달하는 리액트 컴포넌트를 생성하는 함수이다.

const FancyButton = React.forwardRef((props, ref) => (
  <button ref={ref} className="FancyButton">
    {props.children}
  </button>
));

// 부모 컴포넌트에서 FancyButton내부의 button에 ref로 접근할 수 있게 된다.
const ref = useRef();
<FancyButton ref={ref}>Click me!</FancyButton>;

useImperativeHandle

// useImperativeHandle(ref, createHandle, [deps])
function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = React.forwardRef(FancyInput);
// FancyInput의 부모 컴포넌트는 ref.focus()로 FancyInput 내부의 input 태그를 focus할 수 있게 된다.

이렇듯 React.forwardRef와 useImperativeHandle의 조합으로 부모 컴포넌트에서 자식 컴포넌트의 DOM에 접근하고 상호 작용할 수 있게 된다.

callback ref

ref속성에 함수를 전달하는 것을 callback ref라고 하며, ref가 설정되고 해제되는 상황을 세세하게 다루기 위한 api이다.

컴포넌트가 마운트될 때, 리액트는 마운트된 DOM을 해당 callback의 인자로 전달한다.

또한, 컴포넌트가 언마운트될 때에는 null을 해당 callback의 인자로 전달한다.

해당 callback이 호출되는 구체적인 시점은 componentDidMount/componentDidUpdate가 호출되기 이전 시점이다.
(브라우저가 layout을 측정하여 해당 DOM의 위치와 크기를 특정할 수 있는 시점)

이러한 callback ref를 이용하면 다음과 같이 DOM의 위치나 크기를 측정하는 것도 가능하다

function MeasureExample() {
  const [rect, ref] = useClientRect();
  return (
    <>
      <h1 ref={ref}>Hello, world</h1>
      {rect !== null &&
        <h2>The above header is {Math.round(rect.height)}px tall</h2>
      }
    </>
  );
}

function useClientRect() {
  const [rect, setRect] = useState(null);
  const ref = useCallback(node => {
    if (node !== null) {
      setRect(node.getBoundingClientRect());
    }
  }, []);
  return [rect, ref];
}

상기의 예제에서는 컴포넌트의 Lifecycle동안에 h1 엘리먼트가 항상 같은 상태로 존재하기 때문에 마운트 시점에만 height을 측정하기 위해서 dependency로 빈 배열을 넘긴 것이다. 만약 재렌더링할 때 h1 엘리먼트의 크기나 위치 등이 바뀔 수 있다면 ResizeObserver를 활용하는 방법도 있다고 한다.(예제가 없어서...)

dependency를 안 전달해서 매 렌더링마다 측정하는 방법도 있겠다(비효율의 끝이겠지만)

useRef로 DOM의 위치나 크기 측정하기

function MeasureExample() {
  const [rect, ref] = useClientRect();
  return (
    <>
      <h1 ref={ref}>Hello, world</h1>
      {rect !== null &&
        <h2>The above header is {Math.round(rect.height)}px tall</h2>
      }
    </>
  );
}

function useClientRect() {
  const [rect, setRect] = useState(null);
  const ref = useRef();
 
  useEffect(()=>{
    setRect(rec.current.getBoundingClientRect());
  },[]);
  
  return [rect, ref];
}

공식 문서에는 없었지만 상기 예제를 useRef와 useEffect의 조합으로 재구성해봤다. useRef.current가 참조하는 값이 바뀌더라도 재렌더링이 되지 않기 때문에 useEffect와 함께 사용했다. 상기 예제와 비교했을 때 callback ref는 layout이 끝난 시점에 실행되기 때문에 paint하기 이전에 state이 바뀌어 재렌더링이 촉발되고, useEffect는 paint까지 다 끝난 시점에서 실행된다는 점에서 차이가 있다. 불필요한 paint작업을 건너뛸 수 있다는 점에서 전자가 더 유용하기에 공식 문서에서도 전자의 방법으로만 소개한 것이 아닌가 싶다.

인스턴스 변수로 사용하는 useRef

useRef가 반환하는 ref는 어떤 값이든 가질 수 있고 또 변할 수 있는 current 속성을 가진 객체이다. 또한 이 객체는 컴포넌트의 Lifecycle내내 참조를 유지한다.
(함수형 컴포넌트 내에서 일반 변수를 선언한 것과 이 부분에서 다른 듯하다)

일반적으로는 아래와 같이 effect내부나 이벤트 핸들러에서 ref값을 변경한다.
(useEffect 내부에서 flag로도 사용하는 것 같다)

function Timer() {
  const intervalRef = useRef();

  useEffect(() => {
    const id = setInterval(() => {
      // ...
    });
    intervalRef.current = id;
    return () => {
      clearInterval(intervalRef.current);
    };
  });

  // ...
}

또한, 지연 초기화를 수행하지 않는다면 렌더링 도중에 ref를 설정하는 것을 지양해야 한다고 한다.
(렌더링 중에 ref를 설정한다는 게 무슨 의미일까)

ref의 지연 초기화

function Image(props) {
  const ref = useRef(new IntersectionObserver(onIntersect));
  // ...
}

상기의 예제에서는 렌더링을 할 때마다 IntersectionObserver 인스턴스가 새로 생성된다. 이를 지연 초기화하는 방법은 다음과 같다.

function Image(props) {
  const ref = useRef(null);

  function getObserver() {
    if (ref.current === null) {
      ref.current = new IntersectionObserver(onIntersect);
    }
    return ref.current;
  }
}

useState의 지연 초기화와는 달리 따로 api가 제공되지 않기 때문에 위와 같이 초기화 함수를 '선언'해 놓고 필요할 때만 호출하면 된다.

이전 state 혹은 props에 접근하기

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);
  return <h1>Now: {count}, before: {prevCount}</h1>;
}

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}
  1. 브라우저의 paint가 끝나고 effect함수가 실행되어 ref.current에 이전 state값이 저장된다.
    (ref객체의 값이 바뀐 것이므로 재렌더링이 일어나지 않는다!)

  2. setState의 호출로 재렌더링이 일어나고 state값이 갱신된다.

  3. 다시 paint가 끝나고 effect함수가 실행되어 ref값이 다시 갱신되겠지만 paint는 지난 값으로 실행되었기 때문에 이전 state값이 표시되는 것이다.
    (ref객체의 변동이 재렌더링을 불러오지 않는 것을 이용한 트릭?인 것 같다)

이렇듯 재렌더링을 촉발하지 않고, Lifecycle내내 참조를 유지하는 ref를 통해 비동기 콜백에서 최신 state값을 참조하는 등의 작업도 가능하다.

function Example(props) {
  const latestProps = useRef(props);
  useEffect(() => {
    latestProps.current = props;
  });

  useEffect(() => {
    function tick() {
      // 언제든지 최신 props 읽기
      console.log(latestProps.current);
    }

    const id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, []); 
}
  1. paint가 끝나고 ref객체의 current값이 최신 props로 변동된다.

  2. 마운트 시점에 실행된 setInterval은 매초 tick을 호출하는데, useEffect는 props를 dependency로 가지고 있지 않음에도 ref객체를 통해 최신 props에 접근할 수 있다.
    (다만 이런 방식은 그다지 권장되지는 않는다. 애초에 ref 자체를 너무 남발하지 않는 것이 좋다)

0개의 댓글