[Front-end] 개발자 공부

[개발 공부 78일차] React 공식 문서 | Escape Hatches (2)

MOLLY_ 2024. 7. 9. 07:00
728x90

< 목차 >
0. TL;DR
1. ref로 노드 가져오기
2. 직접 만든 컴포넌트의 DOM 노드에 접근하기

3. React가 ref를 부여할 때

 

Ref로 DOM 조작하기 (Manipulating the DOM with Refs)

React는 렌더링 결과물에 맞춰 DOM 변경을 자동으로 처리하기 때문에 컴포넌트에서 자주 DOM을 조작해야 할 필요는 없다.

 

 

 

그럼 뭐 어쩔 때 사용하냐

  1. 특정 노드에 포커스를 옮기거나
  2. 스크롤 위치를 옮기거나
  3. 위치와 크기를 측정하기 위해서 React가 관리하는 DOM 요소에 접근해야 할 때

⇒ React는 이런 작업을 수행하는 내장 방법을 제공하지 않기 때문에, DOM 노드에 접근하기 위한 *ref*가 필요

 

 

0. TL;DR

  1. 한 컴포넌트에서 하나 이상의 ref를 가질 수 있다.
  2. ref는 보통 포커싱, 스크롤링, DOM 요소 크기 혹은 위치 측정을 위해 사용된다.
  3. flushSync로 state 변경을 동적으로 *플러시할 수 있다.

 

💡 플러시 (flushing)

: 컴퓨팅에서 자주 사용되는 용어로, 대기 중인 작업이나 데이터를 강제적으로 처리하거나 전송하는 것

 

 

1. ref로 노드 가져오기

// (1) 먼저 React가 관리하는 DOM 노드에 접근하기 위해 useRef Hook을 import
import { useRef } from 'react';

// (2) 컴포넌트 안에서 ref를 선언하기 위해 방금 가져온 Hook을 사용
const myRef = useRef(null);

// (3) 마지막으로, ref를 DOM 노드를 가져와야하는 JSX tag 에 ref 어트리뷰트로 전달
<div ref={myRef}>

 

 

작동 원리

  1. useRef는 current라는 단일 속성을 가진 객체를 반환한다.
  2. 초기에는 ‘myRef.current’가 ‘null’이 된다.
  3. React가 이 <div>에 대한 DOM 노드를 생성할 때, React는 이 노드에 대한 참조를 myRef.current에 넣는다.

+) 이 DOM 노드를 이벤트 핸들러에서 접근하거나 노드에 정의된 내장 브라우저 API를 사용할 수도 있다.

// 예를 들어, 이렇게 브라우저 API를 사용할 수 있음
myRef.current.scrollIntoView();

 

 

[🩷두근두근🩷 예시 1] 텍스트 입력란에 마우스 커서 이동하기

버튼을 클릭하면 input 요소로 포커스를 이동하게 해보자

import { useRef } from 'react';

export default function Form() {
	// (1) inputRef 선언
  const inputRef = useRef(null);
	
	
	// (3) handleClick 함수에서 inputRef.current에서 input DOM 노드를 읽고,
	// inputRef.current.focus()로 focus() 호출
  function handleClick() {
    inputRef.current.focus();
  }


	// (2) 선언한 inputRef를 <input ref={inputRef}>로 전달
	// "React에 이 <input>의 DOM 노드를 inputRef.current에 넣어줘"
	
	// (4) <button>의 onClick으로 handleClick 이벤트 핸들러 전달
  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

 

 

[🩷두근두근🩷 예시 2] 한 요소로 스크롤 이동하기

// 이미지 3개가 있는 캐러셀(하나의 콘텐츠 영역 내에 여러 개의 서로 다른 콘텐츠를 표시할 수 있는 컴포넌트)이 있음
// 각 버튼은 브라우저 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>
    </>
  );
}

 

 

💡 ref 콜백을 사용하여 ref 리스트 관리하기

위 예시에서는 미리 정해진 숫자만큼 ref가 있었다.

 

하지만 때때로 목록의 아이템마다 ref가 필요할 수도 있고,

얼마나 많은 ref가 필요할지 예측할 수 없는 경우도 있다. 그럴 때 아래의 코드는 작동하지 않는다.

 

<ul>
  {items.map((item) => {
    // 작동하지 않음!
    const ref = useRef(null);
    
    return <li ref={ref} />;
  })}
</ul>

 

왜냐하면 Hook은 컴포넌트의 최상단에서만 호출되어야 하기 때문이다. useRef를 반복문, 조건문 혹은 map() 안쪽에서 호출할 수 없다.

 

이 문제를 해결하려면 어떻게 해야 할까?

  1. 부모 요소에서 단일 ref를 얻고, querySelectorAll과 같은 DOM 조작 메서드를 사용하여 그 안에서 개별 자식 노드를 “찾는” 것이다. 하지만 이는 다루기가 힘들며 DOM 구조가 바뀌는 경우 작동하지 않을 수 있다.

  2. 또 다른 해결책은 ref 어트리뷰트에 함수를 전달하는 것이다. 이것을 “ref 콜백”이라고 한다. React는 ref를 설정할 때 DOM 노드와 함께 ref 콜백을 호출하며, ref를 지울 때에는 null을 전달한다. 이를 통해 자체 배열이나 Map을 유지하고, 인덱스나 특정 ID를 사용하여 어떤 ref에든 접근할 수 있다.

 

// 위 접근법을 사용해서 특정 노드에 스크롤해 보자

import { useRef, useState } from "react";

export default function CatFriends() {
	// itemsRef는 하나의 DOM 노드를 가지고 있지 않음
	// 대신에 식별자와 DOM 노드로 연결된 Map을 가지고 있음
  const itemsRef = useRef(null);
  const [catList, setCatList] = useState(setupCatList);

  function scrollToCat(cat) {
    const map = getMap();
    const node = map.get(cat);
    node.scrollIntoView({
      behavior: "smooth",
      block: "nearest",
      inline: "center",
    });
  }

  function getMap() {
    if (!itemsRef.current) {
      // 처음 사용하는 경우에는, Map을 초기화
      itemsRef.current = new Map();
    }
    
    return itemsRef.current;
  }

  return (
    <>
      <nav>
        <button onClick={() => scrollToCat(catList[0])}>Tom</button>
        <button onClick={() => scrollToCat(catList[5])}>Maru</button>
        <button onClick={() => scrollToCat(catList[9])}>Jellylorum</button>
      </nav>
      <div>
        <ul>
          {catList.map((cat) => (
            <li
              key={cat.id}
              ref={(node) => {
                const map = getMap();
                
                if (node) {
                // Map에 노드를 추가
                  map.set(cat, node);
                  
                } else {
	                // Map에서 노드를 제거
                  map.delete(cat);
                }
              }}
            >
              <img src={cat} />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

function setupCatList() {
  const catList = [];
  for (let i = 0; i < 10; i++) {
    catList.push("https://loremflickr.com/320/240/cat?lock=" + i);
  }

  return catList;
}

// 위 방법으로 이후 Map에서 개별적인 DOM 노드를 읽을 수 있음

 

 

+) ref 콜백을 사용하여 Map을 관리하는 또 다른 접근 방식

<li
  key={cat.id}
  ref={node => {
    const map = getMap();
    
    // Map에 노드를 추가
    map.set(cat, node);

    return () => {
      // Map에 노드를 제거
      map.delete(cat);
    };
  }}
>

 

 

2. 직접 만든 컴포넌트의 DOM 노드에 접근하기

내장 컴포넌트에 ref를 주입할 때 ref의 current 프로퍼티를 그에 해당하는 (<input /> 같은) DOM 노드로 설정한다.

 

하지만 <MyInput /> 같이 직접 만든 컴포넌트에 ref를 주입할 때는 null이 기본적으로 주어진다.

다음의 예시를 통해 쉽게 이해해 보자. 버튼을 클릭할 때 ‘input 요소에 포커스 되지 않는 것 주목하자.

 

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는 아래와 같은 오류 메시지를 출력한다.

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

 

 

React는 기본적으로 다른 컴포넌트의 DOM 노드에 접근하는 것을 허용하지 않는다.

이건 의도적인 설계다. Ref는 자제해서 사용해야 하는 escape hatch다. 다른 컴포넌트의 DOM 노드를 수동으로 조작하면 코드가 훨씬 더 취약해진다.

 

대신, 특정 컴포넌트에서 소유한 DOM 노드를 선택적으로 노출할 수 있다. 컴포넌트는 자식 중 하나에 ref를 ‘전달’하도록 지정할 수 있다. 여기 MyInput이 어떻게 forwardRef API를 사용할 수 있는지 살펴보자.

 

// 아래와 같이 구현하면 버튼을 클릭했을 때 input 요소로 포커스가 잘 이동함
// 그럼 작동 원리를 알아보자

import { forwardRef, useRef } from 'react';

// (2) MyInput 컴포넌트는 forwardRef를 통해 선언됨
// 이건 props 다음에 선언된 두 번째 ref 인수를 통해 상위의 inputRef를 받을 수 있도록 함
const MyInput = forwardRef((props, ref) => {

	// (3) MyInput은 자체적으로 수신받은 ref를 컴포넌트 내부의 <input>으로 전달
  return <input {...props} ref={ref} />;
});


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

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

	// (1) <MyInput ref={inputRef} />으로 React가 대응되는 DOM 노드를
	// inputRef.current에 대입하도록 설정
	  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>
        Focus the input
      </button>
    </>
  );
}

 

이 패턴은 디자인 시스템에서 버튼, 입력 요소 등의 저수준 컴포넌트에서 DOM 노드를 전달하기 위해 매우 흔하게 사용된다. 반면 (form, list, 페이지 섹션 등) 고수준 컴포넌트에서는 의도하지 않은 DOM 구조 의존성 문제를 피하고자 일반적으로 DOM 노드를 노출하지 않는다.

 

 

명령형 처리방식으로 하위 API 노출하기

몇몇 상황에서는 노출된 기능을 제한하고 싶을 수 있는데, 이때 useImperativeHandle을 사용

 

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

const MyInput = forwardRef((props, ref) => {
	// (1) realInputRef는 실제 input DOM 노드를 가지고 있음
  const realInputRef = useRef(null);
  
  // (2) 하지만 useImperativeHandle을 사용하여
  // React가 ref를 참조하는 부모 컴포넌트에 직접 구성한 객체를 전달하도록 지시
  useImperativeHandle(ref, () => ({
    // 오직 focus만 노출
    focus() {
      realInputRef.current.focus();
    },
  }));
  
  return <input {...props} ref={realInputRef} />;
});


export default function Form() {
	// (3) inputRef.current는 foucs 메서드만 가지고 있음
	// 이 경우, ref는 DOM 노드가 아니라 useImperativeHandle 호출에서 직접 구성한 객체가 됨
  const inputRef = useRef(null);

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

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

 

 

3. React가 ref를 부여할 때

React의 모든 업데이트는 두 단계로 나눌 수 있다.

  1. 렌더링 단계: React는 화면에 무엇을 그려야 하는지 알아내도록 컴포넌트를 호출
  2. 커밋 단계: React는 변경사항을 DOM에 적용

 

일반적으로 렌더링하는 중 ref에 접근하는 걸 원하지 않는다.

(1) 첫 렌더링에서 DOM 노드는 아직 생성되지 않아서 ref.current는 null인 상태

(2) 업데이트에 의한 렌더링에서 DOM 노드는 아직 업데이트되지 않은 상태

두 상황 모두 ref를 읽기에 너무 이른 상황

 

 

(1) React는 ref.current를 커밋 단계에서 설정

(2) DOM을 변경하기 전: React는 관련된 ref.current 값을 미리 null로 설정

(3) DOM을 변경한 후 React는 즉시 대응하는 DOM 노드로 재설정

 

대부분의 ref 접근은 이벤트 핸들러 안에서 일어난다.

ref를 사용하여 뭔가를 하고 싶지만, 그것을 시행할 특정 이벤트가 없을 때 Effect가 필요할 수도 있다.

 

 

flushSync로 state 변경을 동적으로 *플러시하기

// 새로운 할 일을 추가하고 할 일 목록의 마지막으로 화면 스크롤을 내리는 예제

// 잘못된 예시의 잘못된 부분
// [문제] 마지막으로 추가된 할 일의 직전으로 항상 스크롤 됨
function handleAdd() {
    const newTodo = { id: nextId++, text: text };
    setText('');
    
    // 이 부분이 문제임
    // setTodos가 DOM을 바로 업데이트하지 않기 때문에 문제가 됨 (Queue에 쌓여 비동기 처리됨)
    // 그래서 할 일 목록의 마지막 노드로 스크롤 할 때, DOM에 아직 새로운 할 일이 추가되지 않은 상태
    setTodos([ ...todos, newTodo]);
    listRef.current.lastChild.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    });
  }


// ------------------ 옳은 예시 구분선 ------------------


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 };
    
    // React가 DOM 변경을 동기적으로 수행하도록 함
    // 이를 위해 react-dom 패키지의 flushSync를 가져오고,
    // state 업데이트를 flushSync 호출로 감싸면 됨
    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)
  });
}

 

 

젠장... 어렵잖아..!

 

 

728x90