코딩을 쉽게 해보자

[React-beta] Escape Hatches - Ref로 DOM 다루기 [번역] 본문

React

[React-beta] Escape Hatches - Ref로 DOM 다루기 [번역]

꿀단지코딩 2023. 2. 10. 17:44

React는 컴포넌트를 자주 다루지 않아도 되도록 자동으로 DOM을 렌더 출력과 일치하도록 업데이트한다.

하지만 React에 의해 다뤄지고 있는 Dom elements를 접근해야 하는 경우가 있다.

예를 들어,

노드를 focus하거나 스크롤, 이것의 크기나 위치를 측정할 때다.

React에서는 이러한 방법을 기본적으로 하는 방법을 제공하지 않으므로, ref를 Dom node에 사용해야할 것이다.

 

 

노드에 ref를 가져오기

React에 의해 다뤄지는 DOM node에 접근하기 위해서는 useRef를 import 해야한다.

그 이후, 컴포넌트 안에서 ref를 선언하고,

마지막으로 DOM node에 ref 어트리뷰트를 넘긴다.

import { useRef } from 'react';
const myRef = useRef(null);
<div ref={myRef}>

useRef 훅은 current라는 하나의 프로퍼티를 가진 객체를 반환한다.

처음에는, myRef.current는 null값이다.

React가 DOM node를 div를 위해서 만들때, React는 myRef.current에 참조를 넣을 것이다.

이 이벤트핸들러를 통해 DOM node에 접근하고 정의된 브라우저 API를 사용할 수 있다.

import { useRef } from 'react';

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

 

DOM 조작이 refs의 가장 흔한 예시이지만, 

React 바깥의 timer IDs와 같은 것을 저장하는데 사용할 수 있다.

state와 유사하게 동일하게, refs는 렌더링 간 유지된다.

Refs는 변해도 리렌더를 일으키지 않는 state 변수와 같다.

 

예시: element 스크롤하기

컴포넌트 안에 하나보다 더 많은 ref를 가져올 수 있다.

예시로, 이미지 3개인 캐러셀이 있다.

각 버튼은 DOM node에 부합하는 브라우저 scrollIntoView() 메소드를 호출함으로써 이미지를 센터에 놓는다.

import { useRef } from 'react';

export default function CatFriends() {
  const firstCatRef = useRef(null);
  const secondCatRef = useRef(null);
  const thirdCatRef = useRef(null);

  function handleScrollToFirstCat() {
    firstCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToSecondCat() {
    secondCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function handleScrollToThirdCat() {
    thirdCatRef.current.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  return (
    <>
      <nav>
        <button onClick={handleScrollToFirstCat}>
          Tom
        </button>
        <button onClick={handleScrollToSecondCat}>
          Maru
        </button>
        <button onClick={handleScrollToThirdCat}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          <li>
            <img
              src="https://placekitten.com/g/200/200"
              alt="Tom"
              ref={firstCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/300/200"
              alt="Maru"
              ref={secondCatRef}
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/250/200"
              alt="Jellylorum"
              ref={thirdCatRef}
            />
          </li>
        </ul>
      </div>
    </>
  );
}

 

Deep Dive: ref 콜백을 사용해 refs 리스트를 관리하기

위 예시에서는 미리 정의된 refs들이 있었다.

하지만 가끔 각각 리스트에서 요소에 대해 ref가 필요한 경우가 있고, 얼마나 많은 요소가 생길지 모른다.

이러한 코드는 동작하지 않는다.

<ul>
  {items.map((item) => {
    // Doesn't work!
    const ref = useRef(null);
    return <li ref={ref} />;
  })}
</ul>

 

 

이것은 훅은 언제나 컴포넌트의 top-level에서 호출되어야 하기 때문이다.

useRef를 loop, condition 또는 map 호출 안에서 호출할 수 없다.

 

한 가지 가능한 방법은 리스트의 부모에 ref를 가져가고 DOM 조작 방법인 querySelectorAll로 각 자식의 노드를 찾는 것이다.

하지만 이건 다루기 힘들도 DOM 구조가 바뀌면 쉽게 망가질 수 있다.

 

다른 방법은 ref 어트리뷰트에 함수를 넘기는 것이다. 이걸 ref callback이라고 부른다.

React는 ref callback을 DOM Node와 함께 ref를 set할때, 없앨때 null을 호출한다.

이건 array나 Map을 유지할 수 있게 하고, 어떤 ref나 index나 id를 통해 접근할 수 있게 한다.

 

이 예시는 긴 목록에서 임의의 노드로 스크롤해 접근하는 방법을 보여준다.

import { useRef } from 'react';

export default function CatFriends() {
  const itemsRef = useRef(null);

  function scrollToId(itemId) {
    const map = getMap();
    const node = map.get(itemId);
    node.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  function getMap() {
    if (!itemsRef.current) {
      // Initialize the Map on first usage.
      itemsRef.current = new Map();
    }
    return itemsRef.current;
  }

  return (
    <>
      <nav>
        <button onClick={() => scrollToId(0)}>
          Tom
        </button>
        <button onClick={() => scrollToId(5)}>
          Maru
        </button>
        <button onClick={() => scrollToId(9)}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul>
          {catList.map(cat => (
            <li
              key={cat.id}
              ref={(node) => {
                const map = getMap();
                if (node) {
                  map.set(cat.id, node);
                } else {
                  map.delete(cat.id);
                }
              }}
            >
              <img
                src={cat.imageUrl}
                alt={'Cat #' + cat.id}
              />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

const catList = [];
for (let i = 0; i < 10; i++) {
  catList.push({
    id: i,
    imageUrl: 'https://placekitten.com/250/200?image=' + i
  });
}

이 예시에서, itemsRef는 하나의 DOM node를 가지고 있지 않는다.

대신, DOM node에 있는 item ID의 Map을 가지고 있다. (Refs는 어떠한 값도 가질 수 있다)

각 아이템의 ref 콜백은 Map을 업데이트 한다.

 

각각의 DOM을 나중에 Map에서 읽을 수 있도록 해준다.

 

다른 컴포넌트의 DOM nodes에 접근하기

브라우저의 요소를 출력하는 <input />과 같은 빌트인 컴포넌트에 ref를 설정하면,

리액트는 ref의 current 프로퍼티에 해당하는 DOM node(실제 브라우저에 있는 <input />)를 설정한다.

 

하지만, 직접 만든 <MyInput />과 같은 컴포넌트에 ref를 설정하고자 하면, default 값은 null일 것이다.

여기에 예시가 있다. 버튼을 클릭하는게 input에 focus 하지 않는다.

import { useRef } from 'react';

function MyInput(props) {
  return <input {...props} />;
}

export default function MyForm() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

이 issue를 알기 위해 리액트는 에러를 콘솔에 띄워준다.

Warning: Function components cannot be given refs. 
Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

이건 리액트가 다른 컴포넌트의 DOM에 접근하지 못하도록 기본 설정이여서 그렇다.

컴포넌트의 자식들도 그런데, 이건 의도적이다.

Refs는 자주 사용하지 않아야 되는 escape hatch이다. 수동으로 다른 컴포넌트의 DOM nodes를 조작하는건 코드를 더 취약해지게 만든다.

 

대신, DOM nodes를 노출하려는 컴포넌트들은 이렇게 해야한다.

컴포넌트는 ref를 자신의 자식 중 하나로 전달할 수 있다.

forwardRef API를 사용한 MyInput이다.

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

이것이 동작하는 원리는

  1. <MyInput ref={inputRef} />이 리액트에게 inputRef.current에 해당하는 DOM node를  넣도록한다.
    하지만, 넣도록 하는 것은 MyInput 컴포넌트에 달려있다. 기본으로는 그렇지 않기 때문이다.
  2. MyInput 컴포넌트가 forwardRef를 통해 선언되어 있다. 이건 상위에서 inputRef가 인수로 props 다음에 선언된 걸 받아온다.
  3. MyInput 자체가 받아놓은 ref를 <input> 안에 전달한다.
import { forwardRef, useRef } from 'react';

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

이제 버튼을 클릭했을 때 input에 focus되는 것이 동작한다.

 

이 디자인 시스템에서, 버튼, 인풋과 같은 low-level 컴포넌트가 그들의 DOM nodes에게 refs를 전달하는 것이 일반적인 패턴이다.

다른 편에선, forms, lists, page sections 같은 high-level 컴포넌트에서는 보통 DOM nodes를 DOM 구조에 대해 일어날 수 있는 의존성 문제를 피하기 위해 노출하지 않는다.

 

Deep Dive: imperative handle을 활용한 API의 부분집합 노출

위 예시에서, MyInput은 original DOM input element를 노출시킨다.

이건 부모 컴포넌트가 focus를 호출할 수 있도록 한다.

하지만, 이건 또한 부모 컴포넌트가 다른 것을 하게 되는데, 예를 들어 CSS style을 바꿀 수 있다.

흔하지 않은 케이스에서, 노출된 기능을 제한시키고 싶을 수도 있다.

이러한 것은 useImperativeHandle을 사용해 할 수 있다.

import {
  forwardRef, 
  useRef, 
  useImperativeHandle
} from 'react';

const MyInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    // Only expose focus and nothing else
    focus() {
      realInputRef.current.focus();
    },
  }));
  return <input {...props} ref={realInputRef} />;
});

export default function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

여기서 MyInput 안에 있는 realInputRef이 실제 input DOM node를 관리한다.

하지만, useImperativeHandle은 React에게 고유한 객체를 ref의 값으로 부모 컴포넌트에게 넘긴다.

Form 안에 있는 inputRef.current는 focus 메소드만 가질 것이다.

이 예시에선, ref DOM node가 아니라, useImperativeHandle 호출에서 만든 custom 객체다.

 

React가 refs를 붙이는 경우

리액트의 업데이트는 두 단계로 나뉜다.

  • 렌더링 중, 리액트는 컴포넌트를 화면에 무엇을 보이게할지 판단하기 위해 호출한다
  • 커밋 중, 리액트는 DOM에 변경사항을 적용한다.

일반적으로 렌더링 중 refs에 접근하는 것은 좋지 않다. DOM nodes를 참조하고 있는 refs도 마찬가지다.

처음 렌더링 중, DOM nodes가 만들어지지 않았을 때, ref.current는 null일 것이다.

그리고 업데이트 렌더링 중, DOM nodes는 업데이트 되지 않을 것이고, 이걸 읽기에는 이를 것이다.

 

리액트가 ref.current를 커밋 중 설정한다. DOM을 업데이트 하기 전에, React는 영향이 간 ref.current 값을 null로 바꾼다.

DOM을 업데이트한 이후, React는 곧바로 해당하는 DOM nodes로 설정한다.

 

보통 이벤트 핸들러에 refs로 접근한다. ref로 무엇을 하려고 하면, 특정한 이벤트가 없을 수 있고 Effect가 필요할 것이다.

 

Deep Dive: flushSync로 동기적으로 state updates를 flush하기.

... 작성중!

'React' 카테고리의 다른 글

[React-beta] Escape Hatches - Ref로 값 참조하기 [번역]  (0) 2023.02.09