개발 공부 기록하기/07. react.js & vue.js

[리액트 공식 문서 번역] useRef

lannstark 2023. 10. 27. 00:47

https://react.dev/reference/react/useRef

 

useRef – React

The library for web and native user interfaces

react.dev

 

useRef는 렌더링을 위해 필요하지 않은 값을 참조할 수 있게 해주는 React Hook이다.

const ref = useRef(initialValue)
  • 컴포넌트의 top level에서 useRef 를 호출하면, ref 를 선언한다. ref 란, 컴포넌트가 어떤 정보를 “기억”하고 싶은데 해당 정보가 리렌더링을 일으키지 않고 싶을 때 사용하는 객체를 말한다.
  • initialValue : ref 객체가 초기화 될 때, current 프로퍼티에 들고 있는 값. 어떤 타입이든 될 수 있다. 이 매개변수는 최초 렌더링 이후 무시된다.

예를 들어,

const ref = useRef(0)

이라는 코드를 사용하면, ref는 아래와 같은 객체가 된다. 즉, useRef 는 아래와 같은 객체를 반환한다.

{
  current: 0
}
  • current : 최초 전달된 initialValue로 정해진다. 추후 다른 값으로 변경할 수 있다.

다음에 렌더링이 일어날 때 useRef 는 동일한 객체를 반환할 것이다.

주요 메커니즘

  • ref.current 값을 변경할 수 있다. ref는 state와 다르게 mutable 하다. 하지만, ref 가 렌더링에 사용되는 객체를 들고 있다면, 이 객체를 변경할 수는 없다.
  • ref.current 값을 바꿀 때 리액트는 컴포넌트를 리렌더링하지 않는다. ref 는 단순한 JS object이기 때문에 리액트는 ref.current 값이 변경되었는지도 모른다.
  • 초기화 과정을 제외하고는, 렌더링 과정에서 ref.current 값을 읽거나 쓰지 않기를 바란다. 만약 그렇지 않다면 컴포넌트의 동작이 예측불가능해진다.
  • Strict Mode에서 리액트는 혹시 모를 오류를 찾기 위해 컴포넌트를 두 번 호출 한다. 이런 동작은 개발환경에서만 존재하고, 운영환경에는 영향을 미치지 않는다. useRef 로 인해 생긴 ref 오브젝트 역시 두 번 생기지만, 그 중 하나는 버려진다. 만약 컴포넌트가 pure 하다면, 행동에 영향을 미치지 않을 것이다.

사용 사례

ref를 이용한 값 참조.

useRef 를 컴포넌트 최상단에서 호출하면, 하나 혹은 그 이상의 ref 를 얻을 수 있다.

import { useRef } from 'react';

function Stopwatch() {
  const intervalRef = useRef(0);
}

useRefcurrent 프로퍼티 하나만 갖고 있는 ref 객체를 주어진 초기값으로 초기화하여 설정한다. 그리고 그 다음 렌더링이 일어날 때 useRef 는 동일한 객체를 반환한다. 정보를 저장하거나 추후 정보를 꺼내기 위해 current 프로퍼티를 바꿀 수 있다. 이는 state 와 비슷하게 느껴지지만, 중요한 차이가 존재한다. ref 를 바꾸는 것은 리렌더링을 일으키지 않는다. 즉, ref 는 컴포넌트의 시각적 결과물에 영향을 주지 않고 정보를 저장하는 최적의 방법이다. 예를 들어, interval ID를 저장하고 나중에 해당 값을 사용해야 한다면, ref 에 집어 넣을 수 있다. ref 안에 있는 값을 변경하기 위해 current 프로퍼티를 수동으로 바꿀 수 있다.

function handleStartClick() {
  const intervalId = setInterval(() => {
    // ...
  }, 1000);
  intervalRef.current = intervalId;
}

나중에, interval ID를 ref로부터 읽어와 interval를 처리할 수 있다.

function handleStopClick() {
  const intervalId = intervalRef.current;
  clearInterval(intervalId);
}

ref 를 사용하기 위해서 다음을 명심해야 한다.

  • 리렌더링 사이에 정보를 저장할 수 있다. (일반적인 변수들은 리렌더링 될 때마다 reset 된다)
  • ref 의 값을 바꾼다고 해서, 리렌더링을 발동시키지 않는다. (state 는 변경되면 리렌더링 시키게 된다)
  • ref 는 컴포넌트의 인스턴스마다 지역적으로 존재한다. (컴포넌트 바깥에 있는 변수는 공유된다)

ref 는 리렌더링을 발동시키지 않기 때문에, 화면에 보이는 정보를 저장하는 목적으로는 부적절하다. 이 경우는 state를 사용해야 한다. https://react.dev/learn/referencing-values-with-refs#differences-between-refs-and-state 를 읽어보라.

Example 1, Click counter

import { useRef } from 'react';

export default function Counter() {
  let ref = useRef(0);

  function handleClick() {
    ref.current = ref.current + 1;
    alert('You clicked ' + ref.current + ' times!');
  }

  return (
    <button onClick={handleClick}>
      Click me!
    </button>
  );
}

이 컴포넌트는 버튼이 얼마나 클릭되었는지 추적하기 위해 ref 를 사용한다. 클릭 횟수는 화면에 보이지 않고, event handler에서만 읽고 쓰이기 때문에 ref 를 사용해도 괜찮다. 만약 JSX 안에서 {ref.current}를 보여주려고 하면, 클릭을 하더라도 숫자가 업데이트 되지는 않을 것이다. 그 이유는 ref.current 의 값을 바꾼다고 해서 리렌더링이 일어나지 않기 때문이다. 리렌더링을 위한 정보 저장은 state에 되어야 한다.

Example 2, A stopwatch

import { useState, useRef } from 'react';

export default function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());

    clearInterval(intervalRef.current);
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        Start
      </button>
      <button onClick={handleStop}>
        Stop
      </button>
    </>
  );
}

이 예시는 stateref의 조합이다. startTimenow 는 리렌더링에 사용되기 때문에 state 이고, 버튼이 눌렸을 때 반복되는 interval를 멈추기 위한 interval id는 ref 이다. interval id는 리렌더링에 사용되지 않기 때문에 ref에 저장하는 것이 적합하고, 수동으로 업데이트할 수 있다.

주의

ref.current 를 렌더링 과정에 읽거나 쓰지 않아야 한다.

React는 컴포넌트의 body가 순수 함수 (pure function) 처럼 동작하기를 바란다.

  • 만약 Input (props, state, context)이 같다면, 항상 동일한 JSX를 반환해야 한다.
  • 순서를 다르게 호출하거나 다른 매개변수를 준다고 해서, 또 다든 호출의 결과에 영향을 줘서는 안된다.

렌더링 정에서 ref 를 읽거나 쓰면 예외를 던질 것이다.

function MyComponent() {
  // ...
  // 🚩 리렌더링 과정에서 ref를 쓰지 마라.
  myRef.current = 123;
  // ...
  // 🚩 리렌더링 과정에서 ref를 읽지 마라.
  return <h1>{myOtherRef.current}</h1>;
}

이벤트 핸들러나 effect 에서는 읽고 쓸 수 있다.

function MyComponent() {
  // ...
  useEffect(() => {
    // ✅ ref를 effect에서 읽거나 쓸 수 있다.
    myRef.current = 123;
  });
  // ...
  function handleClick() {
    // ✅ ref를 이벤트 핸들러에서 읽거나 쓸 수 있다.
    doSomething(myOtherRef.current);
  }
  // ...
}

렌더링 과정에서 어떤 값을 읽고 써야 한다면, state 를 대신 사용해라.

이러한 규칙을 지키지 않는다면, component가 동작은 하겠지만, React에 추가되는 새로운 기능들은 이런 규칙 위해서 동작하기 때문에 잘 호환되지 않을 수 있다. https://react.dev/learn/keeping-components-pure#where-you-_can_-cause-side-effects 도 읽어봐라.

Ref로 DOM 조작하기

DOM을 조작하기 위해 ref 를 사용하는 것은 특히 흔한 일이다. 리액트는 이런 경우를 위한 지원 기능이 있다.

먼저, ref 객체를 null 값으로 초기화 해라.

import { useRef } from 'react';

function MyComponent() {
  const inputRef = useRef(null);
  // ...

그 후 ref 객체를 조작하고 싶은 DOM node의 JSX ref 속성에 넣어라.

// ...
return <input ref={inputRef} />

리액트가 DOM node를 만들고 화면에 띄운 후, React는 ref 오브젝트의 current 프로퍼티를 설정된 DOM node에 연결할 것이다. 이제 <input> 의 DOM node에 접근해 focus() 와 같은 메소드를 호출할 수 있다.

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

React는 해당 node가 화면으로부터 제거되었을 때 current 프로퍼티에 null을 집어 넣는다. https://react.dev/learn/manipulating-the-dom-with-refs 도 읽어보라.

Example 1, Focusing a text input

버튼을 클릭하면, input에 foucs 되도록 만들어보자.

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

Example 2, Scrolling an image into view

버튼을 클릭하면, 해당 이미지가 있는 곳으로 스크롤을 해보자. 이 때 ref가 DOM node를 갖고 있고, DOM의 querySelectorAll API를 이용해 원하는 이미지를 찾을 수 있다.

import { useRef } from 'react';

export default function CatFriends() {
  const listRef = useRef(null);

  function scrollToIndex(index) {
    const listNode = listRef.current;
    // This line assumes a particular DOM structure:
    const imgNode = listNode.querySelectorAll('li > img')[index];
    imgNode.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest',
      inline: 'center'
    });
  }

  return (
    <>
      <nav>
        <button onClick={() => scrollToIndex(0)}>
          Tom
        </button>
        <button onClick={() => scrollToIndex(1)}>
          Maru
        </button>
        <button onClick={() => scrollToIndex(2)}>
          Jellylorum
        </button>
      </nav>
      <div>
        <ul ref={listRef}>
          <li>
            <img
              src="https://placekitten.com/g/200/200"
              alt="Tom"
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/300/200"
              alt="Maru"
            />
          </li>
          <li>
            <img
              src="https://placekitten.com/g/250/200"
              alt="Jellylorum"
            />
          </li>
        </ul>
      </div>
    </>
  );
}

Example 3, Playing and pausing a video

ref 를 이용해 <video> DOM node를 play() 하거나 pause() 해보자.

import { useState, useRef } from 'react';

export default function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);
  const ref = useRef(null);

  function handleClick() {
    const nextIsPlaying = !isPlaying;
    setIsPlaying(nextIsPlaying);

    if (nextIsPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }

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

Example 4, Exposing a ref to your own component

때때로, 당신이 만든 컴포넌트 내부의 DOM을 부모 컴포넌트가 제어해야 하는 경우가 있을 수 있다. 예를 들어, MyInput 컴포넌트를 만든 후, 부모 컴포넌트가 내부의 input에 focus를 할 수 있게 만들어야 한다고 하자. 이런 경우, useRef 가 input을 갖고 있게 만들고, forwardRef 를 이용해 부모 컴포넌트에 노출할 수 있다.

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

ref 컨텐츠가 다시 만들어지는 경우를 피하기

리액트는 ref 값을 최초로 한 번 저장한 이후, 다음 렌더링 때는 이를 무시한다.

function Video() {
  const playerRef = useRef(new VideoPlayer());
  // ...

예를 들면, new VideoPlayer() 의 결과는 최초 렌더링 때만 사용되고 그 후에는 무시되지만, VideoPlayer 의 인스턴스는 렌더링을 할 때마다 생기게 된다. 만약 비싼 객체를 만들어야 한다면 리소스가 낭비되는 셈이다.

이를 해결하기 위해 ref 초기화를 다음과 같이 할 수 있다.

function Video() {
  const playerRef = useRef(null);
  if (playerRef.current === null) {
    playerRef.current = new VideoPlayer();
  }
  // ...

일반적으로 ref.current 를 렌더링 과정에서 읽거나 쓸 수 없다. 하지만, 이 경우는 함수형 컴포넌트의 실행 결과가 항상 같기 때문에 괜찮다.

type checker가 ref에 null이 들어있을 수 있다고 판단하는 것을 피하기 위해 다음과 같은 패턴을 사용할 수도 있다.

function Video() {
  const playerRef = useRef(null);

  function getPlayer() {
    if (playerRef.current !== null) {
      return playerRef.current;
    }
    const player = new VideoPlayer();
    playerRef.current = player;
    return player;
  }

  // ...

문제 해결

직접 만든 컴포넌트에 ref를 사용할 수 없어요.

다음과 같이 직접 만든 컴포넌트에 ref 를 넣으려고 하면,

const inputRef = useRef(null);

return <MyInput ref={inputRef} />;

다음과 같은 에러를 만나게 될 것이다.

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

기본적으로 컴포넌트는 내부에 있는 DOM node에 대한 ref를 노출하지 않는다. 이를 해결하기 위해서는, ref 를 쓰고 싶은 컴포넌트를 forwardRef 로 감싸야 한다.

import { forwardRef } from 'react';

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

export default MyInput;

그럼, 부모 컴포넌트가 ref를 사용할 수 있게 된다.