ref 객체란 무엇이고, 어떻게 활용해볼 수 있을까?

se-een·2023년 6월 11일
1

React 탐구하기

목록 보기
4/7
post-thumbnail

개인적으로 ref 객체 (이하 ref)는 보통 DOM 객체를 바인딩하기 위해서만 사용했었습니다. ref를 사용하지 않고는 input과 같은 DOM 객체를 Auto Focus 할 수 없기 때문이죠. 그리고 ref는 리렌더링을 촉발시키지 않는다. 정도의 수준으로만 알고 있었습니다.

React 공식문서에서 ref를 React의 탈출구 (React의 단방향 데이터 흐름의 탈출구)로 칭하는 것을 보면서 왜 그런지 호기심이 생기더군요. 🧐

이번 편에서는 ref를 알아보고 state와 비교해보며, 더 나아가 어떻게 활용해볼 수 있을지 작성해보고자 합니다. 다음 편에서는 이번 편의 내용을 기반으로 제어 컴포넌트와 비제어 컴포넌트에 대해서 알아보겠습니다.

ref 란

우선 ref를 추가하려면 useRef 훅을 사용해야 합니다. useState와 마찬가지로 초기값을 전달해줘야 하며, 이때 초기값은 모든 데이터 타입으로 지정할 수 있습니다.

const myRef = useRef(null);

위에서 useRef는 myRef 에 다음과 같은 값을 반환합니다.

{
  current : null,
}

따라서 myRef.current 로 ref 값에 접근할 수 있습니다.

그리고 ref는 current 프로퍼티를 갖는 순수 자바스크립트 객체입니다.

ref와 state

Ref와 State의 큰 차이점을 비교하면 위 표와 같습니다. 하나씩 차례로 비교해볼까요?

직접 변이 여부

ref는 ref.current로 직접 접근하여 그 값을 변경시킬 수 있습니다. 이를테면, ref.current = ref.current + 1 과 같은 경우이죠.

하지만 state의 경우 setState를 통해서만 변경할 수 있으므로 직접 변이가 불가능하죠.

즉, ref는 state와는 다르게 렌더링 프로세스 외부에서 ref.current 의 값을 수정하고 업데이트 할 수 있다는 차이점이 있습니다.

리렌더링 촉발 여부

위에서 ref.current 로 접근하여 값을 직접 수정할 수 있다고 하였습니다. 값을 직접 수정하였다고 해서, state의 setState처럼 리렌더링을 촉발하지는 않습니다.

사실 useRef는 React에서 내부적으로 useState를 통해서 구현될 수 있는데요. 다음과 같은 형태를 띌 수 있겠죠.

function useRef(initialValue) {
  const [ref, _] = useState({ current: initialValue });
  return ref;
}

첫 번째 렌더링에서 { current : initalValue } 를 반환하고 React에 의해 저장되므로 렌더링 중에 동일한 객체가 지속해서 반환됩니다. 동일한 객체를 반환해야하므로 setter는 따로 필요하지 않죠.

즉, 위 내용을 기반으로 보면 setter 없이 반환된 객체를 직접 변이하므로 리렌더링을 촉발하지 않는다고 이해하시면 되겠습니다.

렌더링 중에 읽고 쓰기

렌더링 중에 읽고 쓴다는 것이 무엇일까요? 다음 코드를 살펴보며 설명해보겠습니다.

export default function App() {
  const countRef = useRef(0);

  const increaseCount = () => {
    countRef.current = countRef.current + 1;

    console.log(countRef.current);
  };

  return (
    <button onClick={increaseCount}>countRef Value : {countRef.current}</button>
  );
}

버튼을 눌러도 화면에 보이는 countRef.current 값은 업데이트 되지 않습니다. 그 이유는 Ref는 리렌더링을 촉발하지 않기 때문이죠.

하지만 increaseCount 함수 내부의 countRef.current 값은 지속적으로 업데이트 됩니다. 그래서 콘솔에는 countRef.current 값이 1씩 증가되는 것을 확인해볼 수 있죠.

console.log 대신 특정 기능을 하는 로직이 포함되었다면 어땠을까요? 화면에 보이는 countRef.current 값과 로직 연산 중에 쓰이는 countRef.current 값은 서로 상이하기에 의도치 않은 상황을 직면할 수도 있겠죠.

이런 이유 때문에 렌더링 중에 ref.current 값을 읽고 쓰면 코드가 불안정 해진다고 표현합니다.

공식문서 (Pitfall 부분)에서는 useEffect와 이벤트 핸들러 내부에서만 ref의 값을 읽고 쓸 수 있다고 안내하고 있습니다.

렌더링 사이에서 값을 읽고 쓴다면 ref 대신 state를 사용하라고 하네요. 😀

ref의 특성

ref가 보장하는 특성은 다음과 같습니다.

  • 리렌더링을 촉발하지 않음
  • 값이 각각의 컴포넌트에 로컬로 저장됨
  • 리렌더링 사이에 정보를 저장할 수 있음

리렌더링을 촉발하지 않는다는 특징은 위에서 설명했으므로 생략하겠습니다.

각각의 컴포넌트에 로컬로 저장된다는 말은 ref가 useState 훅과 같이 리액트 내부에서 지속적으로 관리되는 것이 아니라 (즉, 클로저의 형태가 아니라) 컴포넌트 내부에 객체로써 존재하기 때문에 그런 것 같습니다. 🧐

리렌더링 사이에 정보 저장 가능

일반 변수와는 달리 ref는 리렌더링 사이에 값이 초기화 되지 않습니다.

export default function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);
  let countGeneral = 0;

  const increaseCount = () => {
    countRef.current = countRef.current + 1;
    setCount(count + 1);
    countGeneral = countGeneral + 1;

    console.log('state : ', count);
    console.log('ref : ', countRef.current);
    console.log('general : ', countGeneral);
  };

  return (
    <>
      <button onClick={increaseCount}>Increase Button</button>
      <div>state : {count}</div>
      <div>ref : {countRef.current}</div>
      <div>general : {countGeneral}</div>
    </>
  );
}

위 코드에서 버튼을 누르면 각각의 count 값이 1씩 증가한 후에 리렌더링이 됩니다.

함수형 컴포넌트에서 리렌더링이라 함은 함수 App 을 재실행 시키는 것이라고 볼 수 있기 때문에, 일반 변수인 countGeneral계속 초기화되어 값이 증가되지 않고 화면에 0이 보여지는 것을 확인할 수 있죠.

하지만 ref는 리렌더링 사이에도 값을 보장하기에 state인 count와 동일한 값을 보여줍니다.

그렇다면 콘솔에는 state 값과 ref 값이 동일하게 찍힐까요?! 🧐

왜 이런 결과가 나온지 모르겠다면 여기에 정리해두었으니 확인해보시고 오시면 좋겠습니다. 😀

위에서 확인해볼 수 있는 사실은 ref는 변경된 그 즉시 변경 내용이 적용되는, 즉 실시간성을 띄는 것 또한 확인해볼 수 있겠네요. (직접 변이가 가능하기에 당연한 사실이지만요.)

ref 를 어떻게 활용해볼 수 있을까?

공식 문서에 의하면 ref는 React를 벗어나 '외부'의 API 등과 통신할 때 사용한다고 설명하고 있습니다.

즉, 다음과 같은 상황에 유용합니다.

  • timeout ID 저장
  • DOM Element를 조작
  • 렌더링과 관련 없는 (JSX를 계산하는데 필요 없는) 값 저장

timeout ID 저장

export default function App() {
  const [input, setInput] = useState('');
  const [isSend, setIsSend] = useState(false);
  const timerId = useRef(null);

  const sendEveryTwoSec = (e) => {
    setInput(e.target.value);

    if (timerId.current) {
      setIsSend(false);
      clearTimeout(timerId.current);
    }

    timerId.current = setTimeout(() => {
      // API 통신 관련 로직

      setIsSend(true);
    }, 2000);
  };

  return (
    <>
      <input
        value={input}
        onChange={sendEveryTwoSec}
        placeholder="보내실 내용을 입력하세요."
      />
      {isSend && <div>sended!</div>}
    </>
  );
}

input 태그에 값을 입력하고, 2초 동안 값이 변경되지 않으면 입력 한 값을 send 하는 간단한 디바운스를 만들어보았습니다.

input 태그 값이 변경될 때 마다 API 통신을 요청하는 것은 매우 비용이 큰 방법이므로, 더 이상 값이 변경되지 않을 때 한 번에 보내는 것이 낫겠죠.

이처럼 디바운스를 구현하기 위해서는 타이머 ID를 기억해두었다가 적절히 clear 해줘야 하기 때문에 이런 경우에 ref가 유용하게 쓰일 수 있습니다.

DOM element 조작

해당 내용은 공식 문서에 잘 설명되어 있고 다음 편에서 (제어 컴포넌트, 비제어 컴포넌트) 다시 다뤄볼 예정이므로 간단한 예제만 들어보겠습니다.

export default function App() {
  const [input, setInput] = useState('');
  const ref = useRef(null);

  const focusInputAfterSend = () => {
    setInput('');
    ref.current.focus();
  };

  return (
    <>
      <input
        ref={ref}
        value={input}
        onChange={(e) => {
          setInput(e.target.value);
        }}
        placeholder="보내실 내용을 입력하세요."
      />
      <button onClick={focusInputAfterSend}>send</button>
    </>
  );
}

send 버튼을 누른 후 input 태그에 Auto Focus를 해주는 로직입니다. Auto Focus는 DOM 엘레먼트에 직접 접근해서 조작해야하기 때문에 ref를 사용해야 합니다.

공식 문서에서는 scrollIntoView 메서드를 활용한 방법 또한 설명하고 있으니 확인해보시면 좋을 것 같습니다. 😀

잘못된 내용이 있다면 지적 부탁드립니다. 🙇

profile
woowacourse 5th FE

4개의 댓글

comment-user-thumbnail
2023년 6월 11일

useState 언제든 쓸 수 있다는 것은 setter을 통해서 변경할 수 있다는 뜻이고, immutable하다는 것은 직접 값을 변경하지 않는다는 뜻인가요? 항상 이부분이 헷갈려요

1개의 답글
comment-user-thumbnail
2023년 6월 14일

요즘 ref에 대해 진짜 아무것도 모르고 있었다는걸 느낍니다.. 좋은 글 감사합니다

1개의 답글