오늘은 useState를 다룰때 미세한 차이지만 좋은 코드 작성에 도움이 되는 것들을 알아보겠습니다.
1. The Functional Updater
import { useState } from "react";
const [count, setCount] = useState(0)
// 🚨 다음 값을 계산할 때 현재 값에 의존하게 됩니다.
<button onClick={() => setCount(count + 1)}>Increment</button>
// ✅ previousCount를 사용해서 다음 값을 계산합니다.
<button onClick={() => setCount(previousCount => previousCount + 1)}>Increment</button>
새로운 값을 setter(setCount)에 전달하지 않고 대신 함수를 전달하는 방법이 있습니다.
리액트는 우리가 함수를 호출한다면 이전 값(previous value)을 제공해주는데요 덕분에 이전 값에 의존해서 다음 값을 계산할 수 있게 됩니다.
이어지는 내용에서 예제에서 작성한 functional updater가 어떤 용도로 사용되는지 확인해보겠습니다.
같은 setter를 여러번 호출할 때
import { useState } from "react";
const Pratice: React.FunctionComponent = () => {
const [count, setCount] = useState(0);
return (
<div className="flex justify-center items-center flex-col">
<button
onClick={() => {
setCount(count + 1);
setCount(count + 1);
}}
>
🚨 예상한대로 작동하지 않습니다. count : {count}
</button>
</div>
);
};
export default Pratice;
onClick 발생 시, setCount를 두번 호출하고 있는데도 count는 1밖에 올라가지 않습니다.
setCount는 count를 즉시 설정하지 않습니다. the useState updater는 update를 예약해주고 React에게 다음과 같이 알려줍니다.
'언젠가' count 값을 새로운 값으로 설정해주세요.
그래서 우리가 setCount를 위의 예제와 같이 호출했다면 다음 문장과 같이 요청한게 됩니다.
count의 값을 2로 설정해주세요
count의 값을 2로 설정해주세요
하지만 우리가 원했던 요청은
현재 count의 값을 증가시켜주세요
현재 count의 값을 증가시켜주세요 (한번 더)
위와 같을 것입니다.
처음 예제에서 previousCount를 사용해서 이전 값에 의존하는 이유가 이제 감이 오셨을 것 같습니다.
import { useState } from "react";
const Pratice: React.FunctionComponent = () => {
const [count, setCount] = useState(0);
return (
<div className="flex justify-center items-center flex-col">
<button
onClick={() => {
setCount((previousCount) => previousCount + 1);
setCount((previousCount) => previousCount + 1);
}}
>
✅ 2가 제대로 증가합니다. count : {count}
</button>
</div>
);
};
export default Pratice;
비동기 작업(async actions)이 관련된 경우
import { useState } from "react";
async function doSomethingAsync() {
return new Promise((r) => setTimeout(r, 500));
}
const Pratice: React.FunctionComponent = () => {
const [count, setCount] = useState(0);
const increment = async () => {
await doSomethingAsync();
setCount(count + 1);
};
return (
<div className="flex justify-center items-center flex-col">
<button onClick={increment}>{count}</button>
</div>
);
};
export default Pratice;
버튼을 클릭하면 onClick 이벤트는 count 값을 증가시키기 전에 비동기 doSomethingAsync 함수가 실행됩니다.
때문에 위 gif 이미지처럼 수십번을 클릭해도 숫자가 10까지도 못오르는 현상이 발생합니다.
그런데 console.log을 increment 함수에 입력하고 확인해보시면 이벤트는 정확히 우리가 클릭한 횟수만큼 발생합니다.
왜 이런 현상이 발생할까요?
정답은 우리가 1초동안 3번 빠르게 클릭해서 count가 겨우 1 올랐다면 setCount(0+1) setCount(0+1) setCount(0+1)
이런식으로 실행됩니다. 원인은 closure(클로저)에 있습니다.
클로저의 정의를 요약해보겠습니다.
자바스크립트에서 클로저는 함수가 만들어지면 매번 생성됩니다. 정확히는 "함수가 생성되었을 때" 입니다.
클로저는 사진을 찍는 것과 비슷합니다. 사진은 찍힌 순간 인물,배경 같은 중요한 것들을 담는데요 코딩으로 생각해보면 함수가 수행하는 작업, 실행중인 코드입니다. 이 인물, 배경이 찍힌 순간의 사진은 영원히 변하지 않습니다.(포토샵은 빼고 생각해보겠습니다..) 함수를 호출한다는 것은 사진을 보고 그 사진을 그대로 따라하는 것과 같습니다.
함수가 생성될 때마다 예전 사진을 버리고 새 사진을 찍습니다. 이걸 코딩으로 생각해보면 리액트가 컴포넌트 트리를 다시 렌더링할 때 전부 하향식으로 다시 실행하는데요. 따로말해 count가 업데이트 되면 pratice 컴포넌트가 다시 렌더링 되기 때문에 increment 함수는 다시 생성됩니다. 우리는 최신 count를 포함하는 사진을 다시 찍는 것입니다.
다시 본문으로 돌아가서 저희가 React에 onClick Props로 increment 함수를 부여하면 increment 함수가 생성될 때 count state 값을 닫아(closes over)버립니다.
우리가 버튼을 클릭하면 increment 함수는 이전 클릭을 기반으로 값을 업데이트 할 기회가 생기기전에 닫아(closes over)버리기 때문에 값이 0으로 유지되는 상황으로 동일한 increment 함수가 계속 호출됩니다.
리액트가 리렌더링 될 때까지 이 현상이 유지됩니다.(리렌더링 되면 함수를 새로 생성해서 값이 증가합니다.)
그렇다면 "비동기 작업 전에 리렌더링을 실행할 수 있다면 해결이 될거 같은데.."라고 생각하실수도 있으실겁니다.
하지만 리렌더링을 해주어도 불가능합니다. increment 함수는 현재 렌더링의 count state에는 액세스가 가능하지만 다음 렌더링의 count state에는 액세스 할 수 없습니다.
더 파고들어보면 저희는 다음 렌더링에서는 완전히 달라진 count state 값에 액세스 할 수 있는 완전히 다른 increment 함수를 가지게 됩니다. 그렇기 때문에 각 렌더링마다 모든 변수의 복사본을 두 개 가질 수 있습니다.(가비지 콜렉션이 알아서 복사본을 정리해주기 때문에 2개의 복사본 최적화에 관해서는 신경쓰지 않으셔도 됩니다. )
하지만 아직 count가 업데이트 되지 않았기 때문에 increment 함수의 복사본이 생성될 때 여전히 값이 0인 것이며, 버튼을 한번 누르고 두번 눌러도 값이 0이 됩니다.
방금 위에 문장이 헷갈리실텐데요, 만약 위 예제의 코드를 한번만 누르고 잠시만 기다려보시면 정상적으로 동작하는 것을 보실 수 있습니다. 이유는 저희가 리렌더링이 될때까지 충분한 시간을 기다렸고 새로운 increment 함수가 최신 값으로 생성되었기 때문입니다.
이런 혼란스러운 현상을 해결하기 위해 첫번째 예제에서 익혔던 The Functional Updater를 사용하겠습니다.
import { useState } from "react";
async function doSomethingAsync() {
return new Promise((r) => setTimeout(r, 500));
}
const Pratice: React.FunctionComponent = () => {
const [count, setCount] = useState(0);
const increment = async () => {
await doSomethingAsync();
setCount((previousCount) => previousCount + 1);
};
return (
<div className="flex justify-center items-center flex-col">
<button onClick={increment}>count: {count}</button>
</div>
);
};
export default Pratice;
이제 저희는 값을 업데이트 할 때 count의 이전 값을 기반으로 업데이트 할 수 있게 되었습니다.
언제든지 이전 값을 기반으로 새로운 값을 계산한다면 The Functional Updater를 사용해주세요.
번외: 종속성 피하기(Avoiding dependencies)
The Functional Updater는 useEffect, useCallback, useMemo에서 종속성을 피할 수 있도록 도와줍니다.
function Counter({ incrementBy = 1 }) {
const [count, setCount] = React.useState(0)
// 🚨 count 값이 변경되면 함수를 다시 생성할 것 입니다.
const increment = useCallback(() => setCount(count + incrementBy), [
incrementBy,
count,
])
// ✅ count를 전혀 사용하지 않으므로 문제를 회피 할 수 있습니다.
const increment = useCallback(
() => setCount((previousCount) => previousCount + incrementBy),
[incrementBy]
)
}
위의 코드에서 memoized(암기된) 자식 컴포넌트에 increment 함수를 전달한다고 가정했을 때, useCallback을 사용한다면 함수가 불필요하게 자주 변경되는 것을 방지 할 수 있지만 만약에 count state를 useCallback의 종속성으로 준다고 가정해보면 여전히 count 값이 변경 될때마다 다시 렌더링 될 것입니다. 이런 경우에 The Functional Updater로 해결 가능합니다.
번외2: useReducer로 state Toggle 하기
import { useState } from "react";
const [value, setValue] = useState(true)
<button onClick={() => setValue(previousValue => !previousValue)}>Toggle</button>
유일하게 원하는 코드가 state 값을 토글하는 것이고 하나의 컴포넌트에서 여러번 반복해서 사용한다면 useReducer가 더 좋은 선택일수도 있습니다. 이유는 아래와 같습니다.
- 토클링 로직을 setter 호출에서 hook 호출로 전환합니다.
- setter가 아니기 때문에 토글 함수의 이름을 정할 수 있습니다.
- 토글 기능을 두번 이상 사용하면 boilerplate의 반복을 줄일 수 있습니다.
import { useReducer } from "react";
const [value, toggleValue] = useReducer(previous => !previous, true)
<button onClick={toggleValue}>Toggle</button>
위 예제는 reducers가 복잡한 state를 처리할때만 유용한 것이 아니며 반드시 이벤트를 dispatch 할 필요가 없는 사례를 보여줍니다.
2. The lazy initializer
useState에 초기 값을 전달하면 초기 변수가 항상 생성되지만 리액트는 첫 렌더링에만 생성된 초기 변수를 사용합니다.
물론 대부분의 경우 문자열 같은 값이 초기 값이라면 상관은 없지만 드물게 state를 초기화 하기 위해 복잡한 계산이 필요한 경우가 있습니다. 복잡한 계산이 필요한 경우라면 보통 IO operation인 localStorage를 읽을 때가 있습니다.
// 🚨 불필요하게 모든 렌더링에서 계산됩니다.
const getInitialState = Number(window.localStorage.getItem('count'))
const [count, setCount] = useState(getInitialState)
이런 경우에 초기 값으로 함수를 전달 할 수 있습니다. 리액트는 정말로 결과가 필요할 때만 이 함수를 실행합니다. (결과가 필요한 때는 보통 컴포넌트가 마운트 될 때입니다.)
// ✅ 코드는 미세하게 다르지만 함수는 오직 한번만 호출됩니다.
const getInitialState = () => Number(window.localStorage.getItem('count'))
const [count, setCount] = useState(getInitialState)
3. The update bailout
updater 함수를 호출할 때 리액트는 항상 컴포넌트를 리렌더링 하지는 않습니다. state가 현재와 동일한 값으로 업데이트 하려 한다면 렌더링이 중단됩니다.
원리는 리액트가 Object.is를 사용하여 값이 다른지 확인합니다.
const Pratice: React.FunctionComponent = () => {
const [name, setName] = useState('Elias')
// 버튼을 클릭해도 컴포넌트가 리렌더링 되지 않습니다.
return (
<div className="flex justify-center items-center flex-col">
<button onClick={() => setName('Elias')}>
Name is: {name}, Date is: {new Date().getTime()}
</button>
</div>
);
};
Reference:
https://tkdodo.eu/blog/things-to-know-about-use-state
Things to know about useState
5 things everyone needs to know about useState
tkdodo.eu
https://kentcdodds.com/blog/use-state-lazy-initialization-and-function-updates
useState lazy initialization and function updates
When to pass a function to useState and setState
kentcdodds.com
https://tkdodo.eu/blog/hooks-dependencies-and-stale-closures
Hooks, Dependencies and Stale Closures
Let's demystify what stale closures are in combination with react hooks with the help of the analogy of taking a photo ...
tkdodo.eu
'Development > React' 카테고리의 다른 글
Next.js)@testing-library로 컴포넌트 테스팅하기 (0) | 2023.01.11 |
---|---|
React) useState에서 놓치기 쉬운 사실들 (3) (0) | 2022.12.26 |
React) useState에서 놓치기 쉬운 사실들 (1) (0) | 2022.12.23 |
Next.js 구글 로그인 구현 (0) | 2022.12.09 |
Next.js 쿠키와 JWT 토큰으로 로그인 구현 (0) | 2022.12.05 |