[ReactJS] ref 에 관하여

eunniverse·2024년 9월 13일

글쓰게된 계기

ReactJS 문서를 계-속 읽고있다! 아마 담주 초정도까지는 계속 읽고, 잘몰랐던 내용에 대해서 작성할 것 같다 ㅎㅎ 그렇다면 오늘도 화이띵..


useRef 동작 방식

useState 와 useRef는 React에 의해 제공되며, useRef는 seState 위에 구현될 수 있다.

// Inside of React
function useRef(initialValue) {
  const [ref, unused] = useState({ current: initialValue });
  return ref;
}

useRef는 setter 가 없는 일반적인 state 변수로 생각하면 된다. 대신 ref.current처럼 사용해야한다.

렌더링 중에 ref.current 를 읽거나 쓰지 마세요!

ref.current 가 언제 변하는지 React는 모르기 때문에 렌더링할 때 읽어도 컴포넌트의 동작을 예측하기가 어렵다. 따라서 렌더링 중에 일부 정보가 필요한 경우 state 를 대신 사용해야한다.


ref vs state

항목refstate
역할DOM 요소나 컴포넌트 인스턴스에 직접 접근컴포넌트 렌더링에 영향을 주는 상태 관리
리렌더링 여부값이 변경되어도 리렌더링을 트리거하지 않음값이 변경되면 컴포넌트가 리렌더링됨
초기화 방법useRef()를 사용하여 초기화useState()를 사용하여 초기화
사용 목적DOM 조작, 저장소 역할(타이머, 외부 라이브러리 등)UI 렌더링에 영향을 주는 데이터 관리
데이터 보존컴포넌트가 리렌더링되어도 값이 유지됨상태 변경 시 리렌더링이 발생하며 데이터가 바뀜
직접 조작 여부값을 직접 수정 가능 (e.g. ref.current = value)setState() 함수를 통해서만 상태를 업데이트 가능
주요 사용처포커스 관리, 스크롤 위치, DOM 요소 접근 등사용자 입력, UI 상태, 비즈니스 로직 상태 관리

ref 리스트 관리하기

ref를 많이 관리하기 위해서 ref 콜백 방식을 사용하면 된다. ref 콜백은 ref 속성에 콜백 함수를 전달하는 방법으로 DOM 요소나 컴포넌트가 렌더링 되거나 업데이트할 때 호출한다.

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

위와 같은 코드는 useRef Hook이 map() 안쪽에서 호출했기 때문에 오류가 발생한다. Hook은 컴포넌트의 최상단에서만 호출되어야한다. 이런 문제를 해결하기 위해서는 ref 콜백 방식을 사용한다. React는 ref를 설정할 때 DOM 노드와 함께 ref 콜백을 호출하며 ref를 지울 때 null을 전달한다.

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])}>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}
              ref={(node) => {
                const map = getMap();
                // ref 처리하기
                if (node) {
                  map.set(cat, node);
                } else {
                  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;
}

내 컴포넌트의 일 부 만 보여주는 방법은?

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

위와 같은 코드는 MyInput 컴포넌트의 DOM 입력 요소를 그대로 노출함으로서 Form 컴포넌트에서 MyInput 컴포넌트를 모두 조작할 수 있게 되었다! 이럴 때 focus() 만 사용하게끔 제한할 수 있는 방법이 있는데..!

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

const MyInput = forwardRef((props, ref) => {
  const realInputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    // 오직 focus만 노출합니다.
    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>
    </>
  );
}

위와 같이 useImperativeHandle를 사용하면 오직 focus() 함수만 사용할 수 있다!


직전 todo까지 scroll이 되는 이유

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

위와 같은 코드를 실행해보면, 최근에 추가한 이전 todo로 scroll이 되는 현상을 볼 수 있다. 분명 todo 추가를 한 후에 scroll 이벤트가 발생하는데 왜 그럴까?

react에서는 state 갱신은 큐에 쌓여 비동기적으로 처리되기 때문에 scrollIntoView() 이벤트가 발생한 후 DOM에 todo 가 추가된다. 이런 문제를 해결하기 위해서 flushSync를 사용하여 동기적으로 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)
  });
}
profile
능력이 없는 것을 두려워 말고, 끈기 없는 것을 두려워하라

0개의 댓글