React useState, useRef 차이

soo·2022년 5월 12일
post-thumbnail

회사에서 선배가 작성한 코드를 감히 나 따위가 리뷰..는 아니고 그냥 조용히 읽어보는데 useRef Hook을 다소 특이하게 쓴 것을 발견했다.

const test = useRef({name: 'Dog', value: 'foot'});

이걸 보기 전까지 나에게 useRef는 하위컴포넌트에 ref 쓰고 싶을 때 forwardRef랑 조합해서만 쓰는 훅이었는데..!
이렇게 useState처럼 값을 저장할 때도 쓴다고? 하고 한번 놀랐고
어, 그러면 useState랑 뭐가 다른거지? 하고 찾아봤다가 이 주제와 관련된 수많은 포스팅들이 있는 것을 발견하고 아 나는 아직 갈 길이 한참 멀었구나 하고 반성했다...

아무튼 차이를 결론만 말하자면,

  • useState는 state의 값이 달라질 때마다 컴포넌트가 리렌더된다.
  • useRef는 ref 값이 변한다고 컴포넌트가 리렌더되지 않는다.

이 차이를 제대로 이해하기 위해서,
수많은 포스팅들에서 공통적으로 많이 사용한 1초마다 화면에 보이는 count가 증가하고, 그 컴포넌트가 unmount될 때 현재의 count가 alert되는 예시 코드를 직접 짜보면서 테스트 및 비교해봤다.

1. useState만 사용할 때

// v1
import { useEffect, useState } from "react";

const HookTest = () => {
  const [counter, setCounter] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      setCounter((prev) => prev + 1);
    }, 1000);
    return () => {
      clearInterval(timer);
      alert(counter);
    };
  }, []);
  return (
    <div>
      <p>{counter}</p>
    </div>
  );
};

export default HookTest;
 

이 코드의 경우 useEffect 내부의 코드는 컴포넌트가 mount될 때 딱 1번 밖에 실행되지 않고, 그렇기에 return문의 alert(counter) 속 counter의 값도 mount될 당시의 값인 0에서 update가 되지 않는다.

//v2
import { useEffect, useState } from "react";

const HookTest = () => {
  const [counter, setCounter] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      setCounter((prev) => prev + 1);
    }, 1000);
    return () => {
      clearInterval(timer);
      alert(counter);
    };
  }, [counter]);
  return (
    <div>
      <p>{counter}</p>
    </div>
  );
};

export default HookTest;

어? 그러면 counter 값이 바뀌면 return문 내부 counter 값도 바뀌게(= useEffect 내부 코드가 실행되게) dependency에 counter 추가해주면 되는 거 아니야?
라는 생각으로 이렇게 코드를 짜면 1초에 1번씩, 즉 counter 값이 증가할 때마다 alert가 발생하게 된다.

이런 문제를 useRef Hook으로 해결할 수 있다.

2. useRef만 사용할 때

import { useEffect, useRef } from "react";

const HookTest = () => {
  const counter = useRef(0);
  useEffect(() => {
    const timer = setInterval(() => {
      counter.current += 1;
    }, 1000);
    return () => {
      clearInterval(timer);
      alert(counter.current);
    };
  }, []);
  return (
    <div>
      <p>{counter.current}</p>
    </div>
  );
};

export default HookTest;

위의 코드를 useState에서 useRef로, 그리고 이에 맞게 countercounter.current로 바꾼 것 외에는 거의 동일하다!
이 경우에는 return문 내부의 alert(counter.current)가 제대로 동작한다.
🚨그렇지만🚨 앞에서 말했듯이 useRef는 값이 바뀐다고 컴포넌트가 리렌더되지 않기 때문에 화면에는 여전히 초기값 = 0이 보이게 된다.

useRef는 .current 프로퍼티로 전달된 인자(initialValue)로 초기화된 변경 가능한 ref 객체를 반환합니다. 반환된 객체는 컴포넌트의 전 생애주기를 통해 유지될 것입니다.
이 기능은 클래스에서 인스턴스 필드를 사용하는 방법과 유사한 어떤 가변값을 유지하는 데에 편리합니다.

공식문서에 나온대로, useRef는 전 생애주기 동안 클로저와 상관없이 유지되는 객체를 반환한다.
그렇기 때문에 useEffect가 mount 때 이후 다시 실행되지 않더라도 current를 통해 증가한 값에 접근이 가능한 것이다.

3. 해결책 (둘 다 사용)

const HookTest = () => {
  const [counter, setCounter] = useState(0);
  const value = useRef(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCounter((prev) => prev + 1);
      value.current += 1;
    }, 1000);
    return () => {
      clearInterval(timer);
      alert(value.current);
    };
  }, []);
  return (
    <div>
      <p>{counter}</p>
    </div>
  );
};

그래서 결론은...
1. 우선 화면에 보이는 counter 값이 업데이트 되게 할려면 컴포넌트가 리렌더돼야 하니깐 매초마다 state counter 값을 setCounter을 통해 업데이트 해준다.
2. 그런데 alert될 때의 값은 이 state counter로는 쓰지 못하니 useRef로도 따로 변수를 만들어서 매초마다 같이 업데이트 해주고 alert에다가는 이 변수의 current 값을 쓴다.

선배가 useState 대신 useRef를 굳이 쓴 건 주석이나 코드들을 미루어 보았을 때 state 업데이트 시 발생하는 무한 렌더링 문제를 피하기 위해서인 것으로 추정된다.
생각해보니 나도 며칠 전에 무한 렌더링 때문에 더는 손보지 못하고 그냥 좀 안 예쁜 채로 던져버린 코드가 있는데, 그 코드도 useRef로 해결할 수 있었을 수도 있을 것 같은데 이미 떠난 버스니...🥲
다음에 무한 렌더링 문제를 마주할 때 적극 활용해보는 것으로!

+ 4. 추가적인 실험

[React.js] useRef와 useState의 용도와 차이
(https://nukw0n-dev.tistory.com/14)

이 포스팅을 보면서 어... 이게 이렇게 된다고? 싶어서 직접 실험해본 추가적인 코드!
state score, ref prevScore, const comment 간의 변화하고 리렌더링되는 순서가 헷갈려서 useEffectuseLayoutEffect, 수동으로 현재 값을 체크하는 버튼을 이용해서 매 시점마다의 값을 직접 찍어보았😂

const Score = () => {
  const [score, setScore] = useState(40);
  const prevScore = useRef(40);

  const comment = prevScore.current === score ? "그대로네요" : "바뀌었네요";

  useEffect(() => {
    console.log(
      "scoreEffect --- prevScore:",
      prevScore.current,
      " & score:",
      score,
      comment
    );
    prevScore.current = score;
    console.log(
      "scoreEffect2 --- prevScore:",
      prevScore.current,
      " & score:",
      score,
      comment
    );
  }, [score]);

  useLayoutEffect(() => {
    console.log(
      "commentLayoutEffect --- prevScore:",
      prevScore.current,
      " & score:",
      score,
      comment
    );
  }, [comment]);

  useEffect(() => {
    console.log(
      "commentEffect --- prevScore:",
      prevScore.current,
      " & score:",
      score,
      comment
    );
  }, [comment]);

  return (
    <>
      <button type="button" onClick={() => setScore((prev) => prev + 20)}>
        시험보기
      </button>
      <span>{`성적이 ${comment}!`}</span>
      <button
        type="button"
        onClick={() => console.log(prevScore.current === score)}
      >
        console
      </button>
    </>
  );
};

export default Score;

위의 5줄이 mount될 때 찍히는 console log이고, 밑의 5줄이 버튼 눌러서 state score을 업데이트 시킨 이후의 console log!
위의 5줄은 볼 것도 없이 all 40"그대로네요", true(이전 점수 === 새 점수)이고,
아래 5줄 중 첫째 줄 => comment가 deps인 LayoutEffect를 시행할 때까지는, 즉 DOM이 그려지기 이전 시점까지는 prevScore.current는 40으로 유지된다. 그래서 이 시점의 comment는 '바뀌었네요'이다.
그리고 DOM이 화면에 그려지고... score가 deps인 useEffect에서 prevScore.current에 score 값을 할당하기 이전 시점. 당연히 아직 prevScore.current는 40이다. 할당한 다음에는 당연히 60점이 되고, comment 값은 이 시점에서 더 이상 변화하지 않기 때문에 여전히 '바뀌었네요'이다.
그리고 comment가 deps인 useEffect. 당연히 60, 60, 바뀌었네요.
마지막으로 비록 화면에선 '바뀌었네요'로 뜨지만 prevScore.current === score이다. 그렇기 때문에 버튼을 누르면 console에는 true가 찍힌다.
이 테스트에서 사실 prevScore, score 값이 업데이트되는 것보다 effect 호출되는 순서가 더 신기했다.

state 변화 발생(리렌더링 필요) 👉 일반 변수 값 update 👉 (일반 변수 값이 deps인) useLayoutEffect 호출 👉 DOM 그리기 완료 👉 state가 deps인 useEffect 호출 👉 일반 변수 값이 deps인 useEffect 호출

좋은 공부 ➕ 실험이었다...


참고한 링크들. 감사합니다 🙏
profile
기록+기록

0개의 댓글