[React] Ref로 DOM 조작

귤티·2025년 4월 29일

front

목록 보기
7/10

React -> 렌더링 결과물에 맞춰 DOM 변경을 자동으로 처리

  • 하지만 가끔 특정 노드에 포커스를 옮기거나, 스크롤 위치를 옮기거나, 위치와 크기를 측정하기 위해서 React가 관리하는 DOM 요소에 접근해야 할 때가 있다.
  • React는 이런 작업을 수행하는 내장 방법을 제공하지 않는다 -> DOM 노드에 접근하기 위한 ref가 필요하다.

ref로 노드 가져오기

React가 관리하는 DOM 노드에 접근하기 위해 useRef Hook을 가져온다.

import { useRef } from 'react';

컴포넌트 안에서 ref를 선언하기 위해 훅 사용

const myRef = useRef(null);

ref를 DOM 노드로 가져와야하는 JSX tag에 ref 속성으로 전달

<div ref={myRef}>

useRef Hook -> current라는 단일 속성을 가진 객체 반환
초기에는 myRef.current가 null

React가 이 div에 대한 DOM 노드를 생성할 때, React는 이 노드에 대한 참조를 myRef.current에 넣는다.
이 DOM 노드를 이벤트 핸들러에서 접근하거나 노드에 정의된 내장 브라우저 API를 사용 가능

myRef.current.scrrollIntoView();

예시: 텍스트 입력에 포커스 이동하기

버튼을 클릭하면 input 요소로 포커스 이동

import { useRef } from 'react';

export default function Form(){
	const inputRef = useRef(null);
    
    function handleClick() {
		inputRef.current.focus();
	}
    
    return (
    	<>
        	<input ref={inputRef} /> // 이 <input>의 DOM 노드를 inputRef.current에 넣어줘
            <button onClick={handleClick}>
            	focut the input
            </button>
        </>
    );
}
  1. inputRef 선언
  2. 선언한 inputRef를 input ref={inputRef}처럼 전달.
  3. handleClick 함수에서 inputRef.current에서 input DOM 노드를 읽고 inputRef.current.focus()로 focus()를 호출
  4. button의 onClick으로 이벤트 핸들러를 전달

useRef Hook은 setTimeout Timer Id 같은 React 외부 요소를 저장하는 용도로도 사용 가능 state와 비슷하게 **ref는 렌더링 사이에도 유지된다**. ref를 설정하더라도 컴포넌트의 렌더링을 다시 유발하지 않는 state와 유사 #### 예시: 한 요소로 스크롤을 이동하기 한 컴포넌트에서 하나 이상의 ref를 가질 수 있다.

이미지 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}>
          Neo
        </button>
        <button onClick={handleScrollToSecondCat}>
          Millie
        </button>
        <button onClick={handleScrollToThirdCat}>
          Bella
        </button>
      </nav>
      <div>
        <ul>
          <li>
            <img
              src="https://placecats.com/neo/300/200"
              alt="Neo"
              ref={firstCatRef}
            />
          </li>
          <li>
            <img
              src="https://placecats.com/millie/200/200"
              alt="Millie"
              ref={secondCatRef}
            />
          </li>
          <li>
            <img
              src="https://placecats.com/bella/199/200"
              alt="Bella"
              ref={thirdCatRef}
            />
          </li>
        </ul>
      </div>
    </>
  );
}

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

때때로 목록의 아이템마다 ref가 필요할 수도 있고, 얼마나 많은 ref가 필요할지 예측할 수 없는 경우도 있다.

<ul>
	{items.map((item) => {
    	// 작동하지 않는다.
        const ref = useRef(null);
        return <li ref={ref} />;
    })}
</ul>
  • 위의 코드는 작동하지 않는다.

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

문제를 해결하는 방법

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

-또 다른 해결책은 ref 속성에 함수를 전달하는 것 => ref 콜백이라고 한다.
React는 ref를 설정할 때 DOM 노드와 함께 ref 콜백을 호출하며, ref를 지울 때에는 null을 전달한다.
-> 이를 통해 자체 배열이나 Map을 유지하고, 인덱스나 특정 id를 사용하여 어떤 ref에든 접근 가능

긴 리스트에서 특정 노드에 스크롤하기 예시:

import { useRef, useState } from "react";

export default function CatFriends() {
  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])}>Neo</button>
        <button onClick={() => scrollToCat(catList[5])}>Millie</button>
        <button onClick={() => scrollToCat(catList[9])}>Bella</button>
      </nav>
      <div>
        <ul>
          {catList.map((cat) => (
            <li
              key={cat}
              ref={(node) => {
                const map = getMap();
                map.set(cat, node);

                return () => {
                  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;
}

itemRef -> 하나의 DOM 노드를 가지고 있지 않다.
대신에 식별자와 DOM 노드로 연결된 Map을 가지고 있다.(Ref는 어떤 값이든 가질 수 있다.)
모든 리스트 아이템에 있는 ref 콜백은 Map 변경을 처리한다.

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

-> 나중에 Map에서 개별 DOM 노드를 읽을 수 있다.

Strict Mode가 활성화되어 있으면 개발 모드에서 ref 콜백이 두 번 실행된다.

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

input 같은 브라우저 요소를 출력하는 내장 컴포넌트에 ref를 주입할 때 React는 ref의 current 프로퍼티를 그에 해당하는 DOM 노드로 설정

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

  • 부모 컴포넌트에서 자식 컴포넌트로 ref를 prop처럼 전달 가능
import { useRef } from 'react';

function MyInput({ ref }){
	return <input ref={ref} />;
}

function MyForm() {
	const inputRef = useRef(null);
    return <MyInput ref={inputRef} />
}

부모 컴포넌트인 MyForm에서 ref를 생성하고, 이를 자식 컴포넌트인 MyInput으로 전달
MyInput은 ref를 input에 넘겨줌
input -> 내장 컴포넌트이므로, React는 해당 ref의 .current 속성을 input DOM 요소로 설정

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

MyInput에 전달된 ref는 DOM 입력 요소로 전달된다.
부모 컴포넌트에서 DOM 노드의 focus()를 호출할 수 있게 되었다.
-> 부모 컴포넌트에서 DOM 노드의 CSS 스타일을 직접 변경하는 등의 예상치 못한 작업 발생 가능

  • 몇몇 상황에서는 노출된 기능을 제한하고 싶음 => useImperativeHandle 사용
import { useRef, useImperativeHandle } from 'react';

function MyInput({ ref }){
	const realInputRef = useRef(null);
    
    useImperativeHandle(ref, () => ({
    	// 오직 focus만 노출
        focus() {
        	realInputRef.current.focus();
        },
    }));
    
    return <input 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 노드를 가지고 있다.
useImperativeHandle을 사용해 React가 ref를 참조하는 부모 컴포넌트에 직접 구성한 객체를 전달하도록 지시.
-> Form 컴포넌트 안쪽의 inputRef.current는 focus 메소드만 가지고 있다.
이 경우 ref는 DOM 노드가 아닌 useImperativeHandle 호출에서 직접 구성한 객체가 된다.

React가 ref를 부여할 때

React의 모든 갱신은 두 단계로 나뉜다.

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

일반적으로 렌더링하는 중에 ref에 접근하는 것을 선호하지 않는다.
DOM 노드를 보유하는 ref도 첫 렌더링에서 DOM 노드는 아직 생성되지 않아서 ref.current는 null인 상태이다. 그리고 갱신에 의한 렌더링에서 DOM 노드는 아직 업데이트되지 않아 둘 다 ref를 읽기엔 너무 이르다.

React는 ref.current를 커밋 단계에서 설정
DOM을 변경하기 전에 React는 관련된 ref.current 값을 미리 null로 설정
DOM을 변경한 후 React는 즉시 대응하는 DOM 노드로 다시 설정

대부분 ref 접근은 이벤트 핸들러 안에서 일어난다.
ref를 사용하여 뭔가를 하고 싶지만, 시행할 특정 이벤트가 없을 때 Effect가 필요할 수도 있다.

flushSync로 state 변경을 동적으로 처리

  • 새로운 할 일을 추가하고 할 일 목록의 마지막으로 화면 스크롤을 아래로 내리는 코드
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에서 state 갱신 -> 큐에 쌓여 비동기적으로 처리됨
여기서 setTodos가 DOM을 바로 업데이트하지 않기 때문에 문제가 발생

할 일 목록의 마지막 노드로 스크롤할 때, DOM에 아직 새로운 투두가 추가가 안 된 상태이다.

이를 해결하기 위해 React가 DOM 변경을 동기적으로 수행하도록 할 수 있다.
react-dom 패키지의 flushSync를 가져오고 state 업데이트를 flushSync 호출로 감싼다.

flushSync(() => {
	setTodos([...todos, newTodo]);
});

listRef.current.lastChild.scrollIntoView();

.
마지막 할 일 -> 스크롤하기 전에 항상 DOM에 추가되어 있을 것이다.

ref로 DOM을 조작하는 모범 사례

ref는 React에서 벗어나야 할 때만 사용해야 한다.

  • 포커스 혹은 스크롤 위치를 관리하거나, React가 노출하지 않는 브라우저 API를 호출하는 등의 작업

DOM을 직접 수정하는 시도를 한다면 React가 만들어 내는 변경 사항과 충돌을 발생시킬 위험을 감수해야 한다.

  1. React 조건부 렌더링과 state를 사용해 노드 존재 여부를 토글

  2. DOM API의 remove()를 사용해 React의 제어 밖에서 노드를 강제적으로 삭제

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가 관리하는 DOM 노드를 직접 바꾸려 하지 말 것

  • 안전하게 React가 업데이트할 이유가 없는 DOM 노드 일부는 수정 가능하다.
    빈 노드에서 엘리먼트를 추가하거나 삭제하는 것은 안전하다.

요약

  • Ref는 일반적으로 DOM 요소를 참조하기 위해 사용한다.
  • <div ref={myRef}>로 React가 myRef.current에 DOM Node를 대입하도록 지시
  • ref는 대부분 포커싱, 스크롤링, DOM 요소 크기 혹은 위치 측정 등 비파괴적 행동에 사용
  • 컴포넌트는 기본적으로 DOM 노드를 노출하지 않는다. forwardRef와 두 번째 ref 인자를 특정 노드에 전달하는 걸로 선택적으로 노출할 수 있다.
  • React가 관리하는 DOM 노드를 바꾸려 하지 말 것
  • React가 관리하는 DOM 노드를 수정하려 한다면, React가 변경할 이유가 없는 부분만 수정할 것

비디오 재생과 멈춤 챌린지

import { useState, useRef } from 'react';

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);
  const videoRef = useRef(null);
  
  function handleClick() {
    const nextIsPlaying = !isPlaying;
    setIsPlaying(nextIsPlaying);
    if(nextIsPlaying){
      videoRef.current.play();
    }
    else {
      videoRef.current.pause();
    }
  }

  return (
    <>
      <button onClick={handleClick}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <video ref={videoRef} width="250">
        <source
          src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
          type="video/mp4"
        />
      </video>
    </>
  )
}

videoRef를 useRef(null)로 초기화 후 선언,
video의 ref로 추가
handleClick 함수에서 로직 설정
videoRef.current.play() || videoRef.current.pause()

이미지 캐러셀 스크롤링 챌린지

  • 활성화된 이미지를 전환하는 Next 버튼 존재
  • Next 버튼 클릭할 때 갤러리가 활성화된 이미지로 수평 스크롤 되도록
node.scrollIntoView({
	behavior: 'smooth',
    block: 'nearest',
    inline: 'center',
});

selectedRef를 선언하고 조건적으로 현재 활성화된 이미지에 전달

<li ref={index === i ? selectedRef : null}>

index === i 조건이 만족할 때 이 이미지가 선택된 이미지임을 알 수 있다.
-> 그 li는 selectedRef를 받는다.
selectedRef.current가 현재 선택된 올바른 DOM 노드를 올바르게 가리키도록 한다.

스크롤 전에 React가 DOM 변경을 끝내기 위해 flushSync 호출이 필요하다는 걸 주의할 것
-> 그렇지 않으면 selectedRef.current는 항상 이전에 선택된 아이템을 가리키고 있을 것이다.

profile
취준 진입

0개의 댓글