SPACE RUMI

Hi, I am rumi. Let's Splattack!

[STUDY] 스터디/React Docs

[5주차] React docs 한글 번역 : Manipulating the DOM with Refs - Refs로 DOM 조작하기

백루미 2023. 3. 18. 14:50
반응형

Ref로 DOM 조작하기

React는 렌더링 출력과 일치하도록 DOM을 자동으로 업데이트하므로 컴포넌트에서 자주 조작할 필요가 없습니다. 하지만 때로는 노드에 초점을 맞추거나 스크롤하거나 크기와 위치를 측정하기 위해 React가 관리하는 DOM 요소에 접근해야 할 수도 있습니다. React에는 이러한 작업을 수행할 수 있는 내장된 방법이 없으므로 DOM 노드에 대한 참조가 필요합니다.

 

학습 내용

  • ref 어트리뷰트를 사용해 React가 관리하는 DOM 노드에 접근하는 방법
  • ref JSX 어트리뷰트가 useRef Hook과 관련되는 방법
  • 다른 컴포넌트의 DOM 노드에 접근하는 방법
  • 어떤 경우에 React가 관리하는 DOM을 수정해도 안전한가?

 

노드에 대한 참조 가져오기 

React가 관리하는 DOM 노드에 접근하려면 먼저 useRef Hook을 가져옵니다:

import { useRef } from 'react';

그런 다음 컴포넌트 내부에서 참조를 선언하는 데 사용합니다:

const myRef = useRef(null);

마지막으로, 이를 DOM 노드에 ref 어트리뷰트로 전달합니다.

<div ref={myRef}>

 

useRef Hook은 current라는 단일 props를 가진 객체를 반환합니다. 처음에 myRef.current는 null이 됩니다. React가 이 <div>에 대한 DOM 노드를 생성할 때, React는 이 노드에 대한 참조를 myRef.current에 넣습니다. 그런 다음 이벤트 핸들러에서 이 DOM 노드에 액세스하고 여기에 정의된 기본 제공 브라우저 API를 사용할 수 있습니다.

// You can use any browser APIs, for example:
myRef.current.scrollIntoView();

예시: 텍스트 입력에 초점 맞추기 
이 예에서는 버튼을 클릭하면 입력에 초점이 맞춰집니다:

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>
    </>
  );
}

이를 구현하려면:

  1. useRef Hook으로 inputRef를 선언합니다.
  2. <input ref={inputRef}>로 전달합니다.
  3. 핸들클릭 함수에서 inputRef.current에서 입력 DOM 노드를 읽은 후 inputRef.current.focus()를 사용하여 해당 노드에 focus()를 호출합니다.
  4. 핸들클릭 이벤트 핸들러를 onClick과 함께 <button>에 전달합니다.

 

DOM 조작이 ref의 가장 일반적인 사용 사례이지만, useRef Hook은 타이머 ID와 같은 다른 것들을 React 외부에 저장하는 데 사용될 수 있습니다. state와 유사하게 ref는 렌더링 사이에 유지됩니다. 참조는 상태 변수와 비슷하지만 설정할 때 재렌더링을 트리거하지 않습니다. 참조로 값 참조하기에서 참조에 대해 읽어보세요.

 

예시: 요소로 스크롤하기 

컴포넌트에는 하나 이상의 참조가 있을 수 있습니다. 이 예시에는 세 개의 이미지로 구성된 캐러셀이 있습니다. 각 버튼은 브라우저의 scrollIntoView() 메서드에서 해당 DOM 노드를 호출하여 이미지를 중앙에 배치합니다:

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 callback을 사용하여 ref 목록을 관리하는 방법

위의 예에서는 미리 정의된 참조 수가 있습니다. 그러나 목록의 각 항목에 대한 참조가 필요한데 얼마나 많은 참조가 필요한지 모르는 경우가 있습니다. 이런 경우에는 작동하지 않습니다:

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

Hook은 컴포넌트의 최상위 레벨에서만 호출해야 하기 때문입니다. 루프, 조건 또는 map() 호출 내부에서 useRef를 호출할 수 없습니다.

이 문제를 해결할 수 있는 한 가지 방법은 부모 엘리먼트에 대한 단일 참조를 가져온 다음 querySelectAll 같은 DOM 조작 메서드를 사용하여 개별 자식 노드를 "찾는" 것입니다. 하지만 이 방법은 깨지기 쉬우며 DOM 구조가 변경되면 중단될 수 있습니다.

또 다른 해결책은 ref 속성에 함수를 전달하는 것입니다. 이를 ref 콜백이라고 합니다. React는 참조를 설정할 때가 되면 DOM 노드로 참조 콜백을 호출하고, 지울 때가 되면 null로 참조 콜백을 호출합니다. 이를 통해 자신만의 배열이나 Map을 유지 관리하고 인덱스나 일종의 ID로 모든 ref에 접근할 수 있습니다.

이 예시는 이 접근 방식을 사용하여 긴 목록에서 임의의 노드로 스크롤하는 방법을 보여줍니다:

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 노드를 보유하지 않습니다. 대신 항목 ID에서 DOM 노드로의 맵을 보유합니다. (Ref는 모든 값을 보유할 수 있습니다!) 모든 목록 항목의 ref 콜백은 맵을 업데이트합니다:

<li
  key={cat.id}
  ref={node => {
    const map = getMap();
    if (node) {
      // Add to the Map
      map.set(cat.id, node);
    } else {
      // Remove from the Map
      map.delete(cat.id);
    }
  }}
>

이렇게 하면 나중에 맵에서 개별 DOM 노드를 읽을 수 있습니다.

 

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

<input />과 같은 브라우저 엘리먼트를 출력하는 내장 컴포넌트에 ref를 넣으면 React는 해당 ref의 현재 프로퍼티를 해당 DOM 노드(예: 브라우저의 실제 <input />)로 설정합니다.

하지만 <MyInput />과 같이 자체 컴포넌트에 참조를 넣으려고 하면 기본적으로 null이 반환됩니다. 다음은 이를 보여주는 예시입니다. 버튼을 클릭해도 입력에 초점이 맞춰지지 않는 것을 확인할 수 있습니다:

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>
    </>
  );
}

문제를 알아차리는 데 도움이 되도록 React는 콘솔에 오류를 출력하기도 합니다:

경고: 함수 컴포넌트에는 참조를 지정할 수 없습니다. 이 ref에 접근하려는 시도는 실패합니다. React.forwardRef()를 사용하려고 하셨나요?

이는 기본적으로 React가 컴포넌트가 다른 컴포넌트의 DOM 노드에 접근하는 것을 허용하지 않기 때문에 발생합니다. 심지어 자신의 자식에게도요! 이것은 의도적인 것입니다. Ref는 탈출구이기 때문에 아껴서 사용해야 합니다. 다른 컴포넌트의 DOM 노드를 수동으로 조작하면 코드가 훨씬 더 취약해집니다.

대신, DOM 노드를 노출하려는 컴포넌트는 해당 동작을 선택해야 합니다. 컴포넌트는 자식 중 하나에 대한 참조를 "전달"하도록 지정할 수 있습니다. MyInput이 forwardRef API를 사용하는 방법은 다음과 같습니다:

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

작동 방식은 다음과 같습니다:

  • <MyInput ref={inputRef} />는 React에게 해당 DOM 노드를 inputRef.current에 넣으라고 지시합니다. 
    하지만 이를 선택할지는 MyInput 컴포넌트에 달려 있으며, 기본적으로 선택하지 않습니다.
  • MyInput 컴포넌트는 forwardRef를 사용해 선언됩니다. 이렇게 하면 props 다음에 선언되는 두 번째 ref 인수로 위의 inputRef를 수신하도록 선택됩니다.
  • MyINput 자체는 수신한 참조를 그 안에 있는 <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>
    </>
  );
}

디자인 시스템에서 버튼, 입력 등과 같은 저수준 컴포넌트는 해당 레퍼런스를 DOM 노드로 전달하는 것이 일반적인 패턴입니다. 반면에 양식, 목록 또는 페이지 섹션과 같은 상위 수준 컴포넌트는 일반적으로 DOM 구조에 대한 우발적인 종속성을 피하기 위해 해당 DOM 노드를 노출하지 않습니다.

 

Deep Dive - 명령형 핸들로 API의 하위 집합 노출하기

위의 예시에서 MyInput은 원래 DOM 입력 엘리먼트를 노출합니다. 이를 통해 부모 컴포넌트가 이 요소에 focus()를 호출할 수 있습니다. 하지만 이렇게 하면 부모 컴포넌트가 다른 작업(예: CSS 스타일 변경)을 할 수도 있습니다. 드문 경우지만 노출되는 기능을 제한하고 싶을 수도 있습니다. 이 경우 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는 실제 입력 DOM 노드를 보유합니다. 하지만 useImperativeHandle은 부모 컴포넌트에 대한 참조 값으로 고유한 특수 객체를 제공하도록 React에 지시합니다. 따라서 Form 컴포넌트 내부의 inputRef.current에는 focus 메서드만 있습니다. 이 경우 참조 "핸들"은 DOM 노드가 아니라 useImperativeHandle 호출 내부에서 생성한 사용자 정의 객체입니다.

 

React가 레퍼런스를 첨부할 때 

React에서 모든 업데이트는 두 단계로 나뉩니다:

렌더링하는 동안 React는 컴포넌트를 호출하여 화면에 무엇이 표시되어야 하는지 파악합니다.
커밋하는 동안 React는 DOM에 변경 사항을 적용합니다.
일반적으로 렌더링 도중에는 참조에 접근하고 싶지 않을 것입니다. 이는 DOM 노드를 보유한 ref도 마찬가지입니다. 첫 번째 렌더링 동안에는 DOM 노드가 아직 생성되지 않았으므로 ref.current는 null이 됩니다. 그리고 업데이트를 렌더링하는 동안에는 DOM 노드가 아직 업데이트되지 않았습니다. 따라서 이를 읽기에는 너무 이르죠.

React는 커밋하는 동안 ref.current를 설정합니다. DOM을 업데이트하기 전에 React는 영향을 받는 ref.current 값을 null로 설정합니다. DOM을 업데이트한 후 React는 즉시 해당 DOM 노드에 설정합니다.

보통 이벤트 핸들러에서 ref에 접근합니다. ref로 무언가를 하고 싶지만 그 작업을 수행할 특정 이벤트가 없는 경우 Effect가 필요할 수 있습니다. 이펙트에 대해서는 다음 페이지에서 설명하겠습니다.

 

Deep Dive - 플러싱 상태는 flushSync와 동기식으로 업데이트됩니다.

새 할 일을 추가하고 목록의 마지막 자식까지 화면을 아래로 스크롤하는 다음과 같은 코드를 살펴보세요. 어떤 이유에서인지 항상 마지막으로 추가한 항목 바로 앞에 있던 할 일로 스크롤되는 것을 볼 수 있습니다:

import { useState, useRef } from 'react';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    setText('');
    setTodos([ ...todos, newTodo]);
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

문제는 이 두 줄에 있습니다:

setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();

React에서는 상태 업데이트가 큐에 대기합니다. 일반적으로 이것은 사용자가 원하는 것입니다. 하지만 여기서는 setTodos가 DOM을 즉시 업데이트하지 않기 때문에 문제가 발생합니다. 따라서 목록을 마지막 요소로 스크롤할 때 할 일이 아직 추가되지 않은 상태입니다. 이것이 스크롤이 항상 한 항목씩 "뒤처지는" 이유입니다.

이 문제를 해결하려면 React가 DOM을 동기적으로 업데이트("플러시")하도록 강제할 수 있습니다. 이렇게 하려면 react-dom에서 flushSync를 가져와서 상태 업데이트를 flushSync 호출로 감싸면 됩니다:

flushSync(() => {
  setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();

이렇게 하면 flushSync로 래핑된 코드가 실행된 직후 React가 DOM을 동기적으로 업데이트하도록 지시합니다. 그 결과, 스크롤을 시도할 때 마지막 할 일이 이미 DOM에 있을 것입니다:

import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';

export default function TodoList() {
  const listRef = useRef(null);
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    flushSync(() => {
      setText('');
      setTodos([ ...todos, newTodo]);      
    });
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }

  return (
    <>
      <button onClick={handleAdd}>
        Add
      </button>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <ul ref={listRef}>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({
    id: nextId++,
    text: 'Todo #' + (i + 1)
  });
}

 

참조를 사용한 DOM 조작 모범 사례 

Ref는 탈출구입니다. "React 외부로 나가야" 할 때만 사용해야 합니다. 일반적인 예로는 focus, 스크롤 위치를 관리하거나 React가 노출하지 않는 브라우저 API를 호출하는 것이 있습니다.

focus와 scroll과 같은 비파괴적인 동작을 고수한다면 문제가 발생하지 않을 것입니다. 하지만 DOM을 수동으로 수정하려고 하면 React가 수행하는 변경 사항과 충돌할 위험이 있습니다.

이 문제를 설명하기 위해 이 예시에는 환영 메시지와 두 개의 버튼이 포함되어 있습니다. 첫 번째 버튼은 React에서 일반적으로 하는 것처럼 조건부 렌더링과 상태를 사용해 그 존재 여부를 전환합니다. 두 번째 버튼은 remove() DOM API를 사용해 React가 제어할 수 없는 DOM에서 강제로 제거합니다.

"Toggle with setState"를 몇 번 눌러보세요. 메시지가 사라졌다가 다시 나타날 것입니다. 그런 다음 "Remove from the DOM"을 누릅니다. 그러면 강제로 제거됩니다. 마지막으로 "Toggle with setState"를 누릅니다:

import {useState, useRef} from 'react';

export default function Counter() {
  const [show, setShow] = useState(true);
  const ref = useRef(null);

  return (
    <div>
      <button
        onClick={() => {
          setShow(!show);
        }}>
        Toggle with setState
      </button>
      <button
        onClick={() => {
          ref.current.remove();
        }}>
        Remove from the DOM
      </button>
      {show && <p ref={ref}>Hello world</p>}
    </div>
  );
}

DOM 엘리먼트를 수동으로 제거한 후 setState를 사용하여 다시 표시하려고 하면 충돌이 발생합니다. 이는 사용자가 DOM을 변경했고 React가 이를 계속 올바르게 관리하는 방법을 모르기 때문입니다.

React가 관리하는 DOM 노드를 변경하지 마세요. React가 관리하는 엘리먼트를 수정하거나, 자식을 추가하거나, 자식을 제거하면 위와 같이 일관성 없는 시각적 결과나 충돌이 발생할 수 있습니다.

하지만 그렇다고 해서 전혀 할 수 없다는 의미는 아닙니다. 주의가 필요합니다. React가 업데이트할 이유가 없는 DOM의 일부를 안전하게 수정할 수 있습니다. 예를 들어, JSX에서 일부 <div>가 항상 비어있는 경우 React는 그 자식 목록을 건드릴 이유가 없습니다. 따라서 수동으로 요소를 추가하거나 제거하는 것이 안전합니다.

 

요약

  • Ref는 일반적인 개념이지만 대부분의 경우 DOM 엘리먼트를 보관하는 데 사용됩니다.
  • <div ref={myRef}>를 전달하여 React에 DOM 노드를 myRef.current에 넣도록 지시합니다.
  • 보통 포커스 맞추기, 스크롤, DOM 엘리먼트 측정과 같은 비파괴적인 동작에 ref를 사용합니다.
  • 컴포넌트는 기본적으로 DOM 노드를 노출하지 않습니다. forwardRef를 사용하고 두 번째 ref 인수를 특정 노드에 전달하여 DOM 노드를 노출하도록 선택할 수 있습니다.
  • React가 관리하는 DOM 노드를 변경하지 마세요.
  • React가 관리하는 DOM 노드를 수정해야 하는 경우 React가 업데이트할 이유가 없는 부분만 수정하세요.

 

과제 1 / 4: 동영상 재생 및 일시 중지하기 

이 예제에서 버튼은 상태 변수를 토글하여 재생과 일시 정지 상태 사이를 전환합니다. 하지만 실제로 동영상을 재생하거나 일시 정지하려면 상태 토글만으로는 충분하지 않습니다. 또한 <비디오>의 DOM 요소에서 재생() 및 일시 중지() 함수를 호출해야 합니다. 여기에 참조를 추가하고 버튼이 작동하도록 하세요.

https://codesandbox.io/s/play-and-pause-the-video-0bslcp?file=/App.js

 

과제 2/4: 검색 필드에 초점 맞추기 

'검색' 버튼을 클릭하면 필드에 초점이 맞춰지도록 설정합니다.

https://codesandbox.io/s/focus-the-search-field-bc3vxq?file=/App.js

 

과제 3/4: 이미지 캐러셀 스크롤하기 

이 이미지 캐러셀에는 활성 이미지를 전환하는 '다음' 버튼이 있습니다. 클릭 시 갤러리가 활성 이미지로 가로 스크롤되도록 합니다. 활성 이미지의 DOM 노드에서 scrollIntoView()를 호출해야 합니다:

https://codesandbox.io/s/scrolling-an-image-carousel-mstveb?file=/App.js

 

과제 4/4: 별도의 구성 요소로 검색 필드에 초점 맞추기 

'검색' 버튼을 클릭하면 필드에 초점이 맞춰지도록 만드세요. 각 구성 요소는 별도의 파일에 정의되어 있으므로 파일 밖으로 이동해서는 안 됩니다. 어떻게 서로 연결할까요?

https://codesandbox.io/s/mystifying-chatelet-58iq6c?file=/SearchInput.js

반응형