Ref, DOM을 조작할 때 쓰자 - new 리액트 공식문서

hongregii·2023년 3월 24일
0

리액트가 자동으로 렌더링 아웃풋과 맞추도록 DOM을 업데이트하기 때문에, 컴포넌트를 직접 조작하는 일은 흔치 않을 것이다. 그러나, 가끔은 리액트가 관리하는 DOM요소에 직접 접근해야 할 필요가 있다. 예를 들어, 어떤 노드에 focus를 주거나, scroll을 넣거나, size / position을 잴 때가 있음. 리액트 자체 코드에서 그것을 해주는 방법은 없다. 따라서, DOM 노드에 ref를 전달해야 한다!

이 문서에서는

  • ref 속성으로 리액트가 관리하는 DOM 노드에 접근하기
  • ref JSX 속성이 useRef 훅과 어떻게 연계되는지
  • 다른 컴포넌트의 DOM 노드에 접근하기
  • 언제 리액트가 관리하는 DOM 노드를 조작하는게 안전한지
    를 배워보자.

노드에 ref를

리액트가 관리하는 DOM 노드에 접근하려면, 먼저 useRef 훅을 import해오자.

import { useRef } from 'react';

컴포넌트 안에 ref를 선언

const myRef = useRef(null);

DOM 노드에 ref 속성으로 전달

<div ref={myRef} >

useRef 훅은 current라는 property 하나만 있는 객체를 리턴한다. 초기값은 null. 리액트가 이 <div>를 위해 DOM 노드를 만들 때, 리액트는 이 노드의 레퍼런스를 myRef.current에 넣을 것이다. 이벤트 핸들러에서 이 DOM 노드에 접근할 수 있고, built-in 브라우저 API 로 사용할 수 있다.

// 아무 브라우저API 사용해도 된다! 예를 들어...
myRef.current.scrollIntoView();

// 아니면
 function handleClick() {
    myRef.current.focus();
 }

// 버튼의 이벤트 핸들러에 이 함수를 넣으면
// 눌렀을 때 ref 된 이미지로 슝 스크롤 될 것이다...
function handleScrollToFirstImage() {
  firstCatRef.current.scrollIntoView({
    behavior: "smooth",
    block: "nearest",
    inline: "center"
  });
}

ref 콜백으로 ref 리스트를 관리하자

위의 예시들에서, ref의 개수가 정해져있다. 그러나 가끔 리스트의 각 item에서 ref가 필요한데, 총 몇개인지 모를 때가 있다. 그 때 이렇게 하면 작동하지 않을 것이다 :

<ul>
  {items.map((item) => {
    // 안됨!
    const ref = useRef(null);
    return <li ref={ref} />;
  })}
  </ul>

왜냐. 훅은 항상 컴포넌트 최상단에서 호출해야 하기 때문. (당연). useRef를 루프/조건/map()안에서 사용하면 안먹힌다!!

야매로 이렇게 할 수 있다. 부모 요소에 ref를 하나 넣어두고, querySelectorAll같은 메서드로 자식 노드를 전부 찾는 방법. 그러나, 이러면 DOM 구조가 바뀌자 마자 깨져버린다.

이렇게 하면 된다. ref 속성에 함수를 넘기는 것. 이것이 바로 ref 콜백 이다. 리액트는 ref를 설정할 때가 됐을 때, DOM 노드와 ref 콜백을 호출할 것이고, clear 할 때가 됐을 때 null을 호출할 것이다. 이러면 array나 Map을 그대로 유지하고, 요소들에 index나 어떤 ID값으로 접근할 수 있게 된다.
Map : 객체라고 생각하면 됨. python의 dict

말로만 하면 이해가 안된다. 예시 투척

// App.js
import { useRef } from 'react';
export default function CatFriends() {
  // ref는 최상단에 선언
  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) {
      // 처음 실행되면 ref에 new Map 넣어주기.
      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 노드를 단 하나도 가지고 있지 않는다. 대신, Map을 들고 있다. 모든 liref 콜백이 Map을 업데이트를 케어함.

<li
  key={cat.id}
  ref={node => {
       const map = getMap();
       if (node) {
         // Map에 추가
         map.set(cat.id, node);
       } else {
         // Map에서 제거
         map.delete(cat.id);
       }
}}

이러면 나중에 개별 DOM 노드를 읽어올 수 있게 됨.

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

배경지식 : built-in component.

리액트 내부 코드에서 정의돼있고, react DOM을 만들 때 자체적으로 쓰는 컴포넌트다. Real DOM에서는 <div></div>, <span></span>, <input> 같은 노드를 쓰지만, built-in 컴포넌트는 이 real dom 요소를 return 하는 컴포넌트다.
예시 : <div />, <span />, <input />

ref를 built-in 컴포넌트에 넘길 때, 리액트는 그 ref의 current prop을 해당 DOM 노드로 설정할 것이다. (react DOM → real DOM)

그러나, ref를 사용자 정의 컴포넌트(여기서는 <MyInput />)에 넘기면, 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 해줘잉
      </button>
    </>
  );
}

콘솔에 이 에러가 뜬다 :

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

Check the render method of MyForm.
at MyInput
at MyForm

리액트가 default로 다른 컴포넌트의 DOM 노드에 접근하는 것을 막기 때문. 자식 컴포넌트여도 얄짤없다! 의도적으로 이렇게 만들었다고 한다. Ref는 비상구이기 때문에, 그렇게 자주 쓰면 안된다. 다른 컴포넌트를 수동으로 조작하면 코드 작동이 더 이상해질 수 있다..

대신에, 자신의 DOM 노드를 노출하고 싶은 컴포넌트들은 노출하고 싶다고 명시적으로 활성화 해야 한다. 컴포넌트는 자신의 ref를 자식에게 "forward 전달" 한다는 것을 지정할 수 있다. forwardRef API를 사용하는 법을 보자:

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

자세히 보면 :

  1. 기존 코드 <MyInput ref={inputRef} /> 는 리액트에게 해당 DOM 노드를 inputRef.current에 넣어달라고 말함. 그러나, 그렇게 넣을지 말지는 MyInput의 선택에 달렸음 - default는 안하기
  2. MyInput 컴포넌트가 forwardRef를 사용해서 선언되면, inputRefref로 받기로 선택할 수 있다!
  3. MyInput<input> 안으로 ref를 넘겨준다.

그러니까 이렇게 해야 작동함 :

import { forwardRef, useRef } from 'react';

// 자식 컴포넌트 - 부모로부터 inputRef 넘겨받음
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>
    </>
  );
}

button, input과 같은 로우 레벨 컴포넌트에서는 forwardRef를 통해 ref를 DOM 노드로 넘겨주는 경우가 흔하다. 그러나 form, list, page같은 하이 레벨 컴포넌트는 보통 DOM 노드를 노출하지 않기 때문에 잘 하지 않는다. 실수로 DOM 구조에 영향을 줄 수 있기 때문.

useImperativeHandle 훅?

공식문서 내용이 깔끔하지 않다. 여기서는 chatGPT 센세의 힘을 빌려보자...

우선, Imperative ?

Imperative는 명령적인, 명령어의 형태로 작성된, 명령을 내리는 것을 의미하는 형용사입니다. JavaScript에서의 Imperative Programming은 코드에서 명령어를 직접 사용하여 코드가 어떻게 수행되는지를 명확하게 정의하는 프로그래밍 방식입니다. 이와 반대되는 개념으로는 Declarative Programming이 있습니다.

useImperativeHandle ?

useImperativeHandle은 컴포넌트에서 ref를 사용할 때 유용한 Hook으로, 자식 컴포넌트에서 전달받은 ref를 직접 사용할 수 있는 API를 노출할 수 있습니다.

왜 쓰냐 ?

React에서 Ref를 사용하면 부모 컴포넌트에서 자식 컴포넌트의 DOM에 직접적으로 접근할 수 있습니다. 하지만 자식 컴포넌트에서 Ref를 직접 사용하는 것은 권장되지 않습니다.
그러니까, 아주 많은 browser API 중에서 input.focus() 같이 그때그때 필요한 것들만 개발자가 사용할 수 있게끔, 명시적으로 "노출" 해준다는 것이다!

예시

useImperativeHandle(ref, () => ({
    // focus 만 쓸거니까 허락해줘잉
    focus() {
      realInputRef.current.focus();
    },
  }));

ref, 리액트가 언제 사용할까?

리액트에서 모든 업데이트는 두 가지 페이즈로 나뉜다 :

  • 렌더링 페이즈 : 화면 위에 무엇이 있어야 하는지 알기 위해 컴포넌트(함수)를 호출
  • 커밋 페이즈 : 변동사항을 DOM에 적용

일반적으로, 렌더링 시 ref에 접근하면 안된다. DOM 노드를 품은 ref도 마찬가지. 첫번째 렌더에서, DOM 노드는 아직 만들어지지 않았기 때문에 ref.currentnull이 된다. 렌더링 업데이트 중에는 DOM 노드가 아직 업데이트 되지 않았다. 즉, 아직 DOM 노드를 읽어오기에는 이르다 (읽어올 필요도 없다)

리액트는 ref.current커밋 중에 설정한다.

  • DOM을 업데이트 하기 전 : ref.current 값은 null로 설정한다.
  • DOM 업데이트 이후 : ref.current값을 즉시 그에 맞는 DOM 노드로 설정한다.

보통 ref를 이벤트 핸들러에서 접근할 것이다. ref로 무언가를 하고 싶은데, 딱히 이벤트가 없으면 Effect를 사용하면 될 것이다. 다음장에서...

ref로 DOM 조작, 이럴 때 주의하자

Ref는 탈출구다. 리액트 밖으로 나가야 할 때 사용해야 한다. 가장 흔한 예시는 focus, scroll, 또는 리액트로 접근할 수 없는 다른 브라우저 API를 사용해야 할 때다.
focus나 scroll같은 평화로운 액션만 한다면, 아무 문제가 없을 것이다. 그러나 DOM을 수동으로 조작하려고 하면 충돌이 생길 수 있다.
예시가 너무 안 겪을 것 같은 상황이라 패스

아무튼, 비어있는 <div>같이 아무 의미 없는 DOM 요소는 ref로 remove 하거나 수정해도 괜찮지만, 리액트가 모르는 DOM 요소를 ref로 마구 없애거나 만들지 말라는 말이다.

profile
잡식성 누렁이 개발자

0개의 댓글