회사에서 선배가 작성한 코드를 감히 나 따위가 리뷰..는 아니고 그냥 조용히 읽어보는데 useRef Hook을 다소 특이하게 쓴 것을 발견했다.
const test = useRef({name: 'Dog', value: 'foot'});
이걸 보기 전까지 나에게 useRef는 하위컴포넌트에 ref 쓰고 싶을 때 forwardRef랑 조합해서만 쓰는 훅이었는데..!
이렇게 useState처럼 값을 저장할 때도 쓴다고? 하고 한번 놀랐고
어, 그러면 useState랑 뭐가 다른거지? 하고 찾아봤다가 이 주제와 관련된 수많은 포스팅들이 있는 것을 발견하고 아 나는 아직 갈 길이 한참 멀었구나 하고 반성했다...
아무튼 차이를 결론만 말하자면,
useState는 state의 값이 달라질 때마다 컴포넌트가 리렌더된다.useRef는 ref 값이 변한다고 컴포넌트가 리렌더되지 않는다.이 차이를 제대로 이해하기 위해서,
수많은 포스팅들에서 공통적으로 많이 사용한 1초마다 화면에 보이는 count가 증가하고, 그 컴포넌트가 unmount될 때 현재의 count가 alert되는 예시 코드를 직접 짜보면서 테스트 및 비교해봤다.
// 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으로 해결할 수 있다.
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로, 그리고 이에 맞게 counter을 counter.current로 바꾼 것 외에는 거의 동일하다!
이 경우에는 return문 내부의 alert(counter.current)가 제대로 동작한다.
🚨그렇지만🚨 앞에서 말했듯이 useRef는 값이 바뀐다고 컴포넌트가 리렌더되지 않기 때문에 화면에는 여전히 초기값 = 0이 보이게 된다.
useRef는 .current 프로퍼티로 전달된 인자(initialValue)로 초기화된 변경 가능한 ref 객체를 반환합니다. 반환된 객체는 컴포넌트의 전 생애주기를 통해 유지될 것입니다.
이 기능은 클래스에서 인스턴스 필드를 사용하는 방법과 유사한 어떤 가변값을 유지하는 데에 편리합니다.
공식문서에 나온대로, useRef는 전 생애주기 동안 클로저와 상관없이 유지되는 객체를 반환한다.
그렇기 때문에 useEffect가 mount 때 이후 다시 실행되지 않더라도 current를 통해 증가한 값에 접근이 가능한 것이다.
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로 해결할 수 있었을 수도 있을 것 같은데 이미 떠난 버스니...🥲
다음에 무한 렌더링 문제를 마주할 때 적극 활용해보는 것으로!
[React.js] useRef와 useState의 용도와 차이
(https://nukw0n-dev.tistory.com/14)
이 포스팅을 보면서 어... 이게 이렇게 된다고? 싶어서 직접 실험해본 추가적인 코드!
state score, ref prevScore, const comment 간의 변화하고 리렌더링되는 순서가 헷갈려서 useEffect와 useLayoutEffect, 수동으로 현재 값을 체크하는 버튼을 이용해서 매 시점마다의 값을 직접 찍어보았😂
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 호출
좋은 공부 ➕ 실험이었다...