Ref로 값 참조하기

컴포넌트가 일부 정보를 “기억”하고 싶지만, 해당 정보가 렌더링을 유발하지 않도록 하려면 ref를 사용하세요.

학습 내용

  • 컴포넌트 ref를 어떻게 추가하는가
  • ref의 값이 어떻게 업데이트되는가
  • ref가 state와 어떻게 다른가
  • ref를 어떻게 안전하게 사용할까

컴포넌트에 ref를 추가하기

React에서 useRef Hook을 가져와 컴포넌트에 ref를 추가할 수 있습니다.

import { useRef } from 'react';

컴포넌트 내에서 useRef Hook을 호출하고 참조할 초깃값을 유일한 인자로 전달합니다. 예를 들어 다음은 값 0에 대한 ref 입니다.

const ref = useRef(0);

useRef 는 다음과 같은 객체를 반환합니다.

{
current: 0 // useRef에 전달한 값
}
An arrow with 'current' written on it stuffed into a pocket with 'ref' written on it.

Illustrated by Rachel Lee Nabors

ref.current 프로퍼티를 통해 해당 ref의 current 값에 접근할 수 있습니다. 이 값은 의도적으로 변경할 수 있으므로 읽고 쓸 수 있습니다. React가 추적하지 않는 구성 요소의 비밀 주머니라 할 수 있습니다. (이것이 바로 React의 단방향 데이터 흐름에서 “escape hatch”가 되는 것입니다—아래에서 자세히 설명하고 있습니다!)

여기서 버튼은 클릭할 때마다 ref.current를 증가시킵니다.

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는 숫자를 가리키지만, state처럼 문자열, 객체, 심지어 함수 등 모든 것을 가리킬 수 있습니다. state와 달리 ref는 읽고 수정할 수 있는 current 프로퍼티를 가진 일반 자바스크립트 객체입니다.

컴포넌트는 모든 증가에 대하여 다시 렌더링 되지 않습니다. state와 마찬가지로 ref도 React에 리렌더에 의해 유지됩니다. 그러나, state를 설정하면 컴포넌트가 다시 렌더링 됩니다. ref를 변경하면 다시 렌더링 되지 않습니다!

예시: 스톱워치 작성하기

ref와 state를 단일 컴포넌트로 결합할 수 있습니다. 예를 들어 사용자가 버튼을 눌러 시작하거나 중지할 수 있는 스톱워치를 만들어봅시다. 사용자가 “시작”을 누른 후 시간이 얼마나 지났는지 표시하려면 시작 버튼을 누른 시기와 현재 시각을 추적해야 합니다. 이 정보는 렌더링에 사용되므로 state를 유지합니다.

const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);

사용자가 “시작”을 누르면 setInterval을 사용하여 10밀리초마다 시간을 업데이트합니다.

import { useState } from 'react';

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

  function handleStart() {
    // 카운팅을 시작합니다.
    setStartTime(Date.now());
    setNow(Date.now());

    setInterval(() => {
      // 10ms 마다 현재 시간을 업데이트 합니다. 
      setNow(Date.now());
    }, 10);
  }

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

”Stop” 버튼을 누르면 now state 변수의 업데이트를 중지하기 위해 기존 interval을 취소해야 합니다. 이를 위해 clearInterval을 호출하면 됩니다. 그러나 이전에 사용자가 시작을 눌렀을 때 setInterval 호출로 반환된 interval ID를 제공해야 합니다. interval ID는 어딘가에 보관해야 합니다. interval ID는 렌더링에 사용되지 않으므로 ref에 저장할 수 있습니다.

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

렌더링에 정보를 사용할 때 해당 정보를 state로 유지합니다. event handler에게만 필요한 정보이고 변경이 일어날 때 리렌더가 필요하지 않다면, ref를 사용하는 것이 더 효율적일 수 있습니다.

ref와 state의 차이

ref가 state보다 덜 “엄격한” 것으로 생각될 수 있습니다-예를 들어, 항상 state 설정 함수를 사용하지 않고 변경할 수 있습니다. 하지만 대부분은 state를 사용하고 싶을 것입니다. ref는 자주 필요하지 않은 “escape hatch”입니다. state와 ref를 비교한 것은 다음과 같습니다.

refsstate
useRef(initialValue){ current: initialValue } 을 반환합니다.useState(initialValue) 은 state 변수의 현재 값과 setter 함수 [value, setValue] 를 반환합니다.
state를 바꿔도 리렌더 되지 않습니다.state를 바꾸면 리렌더 됩니다.
Mutable-렌더링 프로세스 외부에서 current 값을 수정 및 업데이트할 수 있습니다.”Immutable”—state 를 수정하기 위해서는 state 설정 함수를 반드시 사용하여 리렌더 대기열에 넣어야 합니다.
렌더링 중에는 current 값을 읽거나 쓰면 안 됩니다.언제든지 state를 읽을 수 있습니다. 그러나 각 렌더마다 변경되지 않는 자체적인 state의 snapshot이 있습니다.

다음은 state와 함께 구현되는 카운터 버튼입니다.

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <button onClick={handleClick}>
      You clicked {count} times
    </button>
  );
}

count 값이 표시되므로 state 값을 사용하는 것이 타당합니다. 카운터의 값이 setCount()로 설정되면 React는 컴포넌트를 다시 렌더링하고 새 카운트를 반영하도록 화면이 업데이트됩니다.

이를 ref와 함께 구현하려고 하면 React는 컴포넌트를 다시 렌더링하지 않으므로 카운트가 변경되는 것을 볼 수 없습니다! 이 버튼을 클릭해도 텍스트가 업데이트되지 않는 방법을 확인해봅시다.

import { useRef } from 'react';

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

  function handleClick() {
    // 이것은 컴포넌트의 리렌더를 일으키지 않습니다!
    countRef.current = countRef.current + 1;
  }

  return (
    <button onClick={handleClick}>
      You clicked {countRef.current} times
    </button>
  );
}

이것이 render 중에 ref.current를 출력하면 신뢰할 수 없는 코드가 나오는 이유입니다. 이 부분이 필요하면 state를 대신 사용해야 합니다..

Deep Dive

useRef는 내부적으로 어떻게 동작하나요?

useStateuseRef가 모두 React에 의해 제공되지만, 원칙적으로 useRefuseState 위에 구현될 수 있습니다. React 내부에서 useRef가 이렇게 구현되는 것을 상상할 수 있습니다.

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

첫 번째 렌더 중에 useRef{ current: initialValue }을 반환합니다. 이 객체는 React에 의해 저장되므로 다음 렌더 중에 같은 객체가 반환됩니다. 이 예시에서는 state setter가 어떻게 사용되지 않는지 주의하세요. useRef는 항상 동일한 객체를 반환해야 하므로 필요하지 않습니다!

React는 useRef가 실제로 충분히 일반적이기 때문에 built-in 버전을 제공합니다. setter가 없는 일반적인 state 변수라고 생각할 수 있습니다. 객체 지향 프로그래밍에 익숙하다면 refs는 인스턴스 필드를 상기시킬 수 있습니다-하지만 this.something 대신에 somethingRef.current 처럼 써야 합니다.

refs를 사용할 시기

일반적으로 컴포넌트가 React를 “외부”와 외부 API—컴포넌트의 형태에 영향을 미치지 않는 브라우저 API 와 통신해야 할 때 ref를 사용합니다. 다음은 몇 가지 특별한 상황입니다.

컴포넌트가 일부 값을 저장해야 하지만 렌더링 로직에 영향을 미치지 않는 경우, refs를 선택합니다.

refs의 좋은 예시

다음 원칙을 따르면 컴포넌트를 보다 쉽게 예측할 수 있습니다.

  • refs를 escape hatch로 간주합니다. Refs는 외부 시스템이나 브라우저 API로 작업할 때 유용합니다. 애플리케이션 로직과 데이터 흐름의 상당 부분이 refs에 의존한다면 접근 방식을 재고해 보는 것이 좋습니다.
  • 렌더링 중에 ref.current를 읽거나 쓰지 마세요. 렌더링 중에 일부 정보가 필요한 경우 state를 대신 사용하세요. ref.current가 언제 변하는지 React는 모르기 때문에 렌더링할 때 읽어도 컴포넌트의 동작을 예측하기 어렵습니다. (if (!ref.current) ref.current = new Thing() 과 같은 코드는 첫 번째 렌더 중에 ref를 한 번만 설정하는 경우가 예외입니다.)

React state의 제한은 refs에 적용되지 않습니다. 예를 들어 state는 모든 render에 대한 snapshot동기적으로 업데이트되지 않는 것과 같이 작동합니다. 그러나 ref의 current 값을 변조하면 다음과 같이 즉시 변경됩니다.

ref.current = 5;
console.log(ref.current); // 5

그 이유는 ref 자체가 일반 자바스크립트 객체처럼 동작하기 때문입니다.

또한 ref로 작업할 때 mutation 방지에 대해 걱정할 필요가 없습니다. 변형하는 객체가 렌더링에 사용되지 않는 한, React는 ref 혹은 해당 콘텐츠를 어떻게 처리하든 신경 쓰지 않습니다.

Refs 와 DOM

임의의 값을 ref로 지정할 수 있습니다. 그러나 ref의 가장 일반적인 사용 사례는 DOM 엘리먼트에 액세스하는 것입니다. 예를 들어 프로그래밍 방식으로 입력의 초점을 맞추려는 경우 유용합니다. <div ref={myRef}>와 같은 JSX의 ref 어트리뷰트에 ref를 전달하면 React는 해당 DOM 엘리먼트를 myRef.current에 넣습니다. 만약 엘리먼트가 DOM 에서 사라지면, React 는 myRef.current 값을 null 로 업데이트 합니다. 이에 대한 자세한 내용은 Refs를 사용하여 DOM 조작에서 확인할 수 있습니다.

요약

  • Refs는 렌더링에 사용되지 않는 값을 고정하기 위한 escape hatch이며, 자주 필요하지는 않습니다.
  • ref는 읽거나 설정할 수 있는 current라는 프로퍼티를 호출할 수 있는 자바스크립트 순수객체입니다.
  • useRef Hook을 호출해 ref를 달라고 React에 요청할 수 있습니다.
  • state와 마찬가지로 ref는 컴포넌트의 렌더링 간에 정보를 유지할 수 있습니다.
  • state와 달리 ref의 current 값을 설정하면 리렌더가 트리거되지 않습니다.
  • 렌더링 중에 ref.current를 읽거나 쓰지 마세요. 컴포넌트를 예측하기 어렵게 만듭니다.

챌린지 1 of 4:
정상적으로 동작하지 않는 채팅 입력창 수정

메시지를 입력하고 “Send”를 클릭합니다. “Sent!” 경고창(alert)이 나타나기 전에 3초 정도 지연된다는 것을 알 수 있습니다. 이 지연된 시간 동안 “Undo” 버튼을 볼 수 있습니다. 누르세요. 이 “Undo” 버튼은 “Sent!” 메시지가 나타나지 않도록 합니다. handleSend 중 저장된 timeout ID에 대해 clearTimeout을 호출하면 됩니다. 그러나 “Undo”를 클릭한 후에도 “Sent!” 메시지가 계속 나타납니다. 왜 작동이 되지 않는지 찾아서 고쳐봅시다.

import { useState } from 'react';

export default function Chat() {
  const [text, setText] = useState('');
  const [isSending, setIsSending] = useState(false);
  let timeoutID = null;

  function handleSend() {
    setIsSending(true);
    timeoutID = setTimeout(() => {
      alert('Sent!');
      setIsSending(false);
    }, 3000);
  }

  function handleUndo() {
    setIsSending(false);
    clearTimeout(timeoutID);
  }

  return (
    <>
      <input
        disabled={isSending}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button
        disabled={isSending}
        onClick={handleSend}>
        {isSending ? 'Sending...' : 'Send'}
      </button>
      {isSending &&
        <button onClick={handleUndo}>
          Undo
        </button>
      }
    </>
  );
}