React state 스냅샷과 업데이터 함수
버튼 하나로 숫자를 3 올리는 코드입니다.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}}>
{count}
</button>
);
}누르면 3 이 아니라 1 이 됩니다. 한 번 더 누르면 2 입니다. setCount 를 세 번 불렀는데 한 번만 반영된 것처럼 보입니다.
setState 가 변수를 바로 바꾼다는 생각
흔히 setCount(count + 1) 을 count = count + 1 의 React 판으로 이해합니다. 값을 바꾸는 함수이고, 부르고 나면 count 가 바뀌어 있다고 봅니다. 그렇다면 세 번 부르면 0, 1, 2 를 거쳐 3 이 되어야 합니다.
이 이해는 다음 코드에서도 틀린 답을 냅니다.
function handleClick() {
setName("Robin");
console.log(name); // "Robin" 이 찍힐 것 같지만 옛 값이 찍힌다
}useState 문서는 이 경우를 함정으로 따로 적어 둡니다. set 함수는 이미 실행 중인 코드의 state 를 바꾸지 않고, 다음 렌더링부터 useState 가 돌려줄 값에만 영향을 준다고 설명합니다.
렌더링마다 찍히는 state 의 사진
이것을 이해하려면 앞 글의 정의에서 출발하면 됩니다. 렌더링은 React 가 컴포넌트 함수를 호출하는 것입니다. 함수가 호출될 때마다 useState 는 그 시점의 값을 돌려주고, 그 값은 함수 안의 평범한 상수입니다.
State as a Snapshot 문서는 이것을 사진에 비유합니다.
The JSX you return from that function is like a snapshot of the UI in time. Its props, event handlers, and local variables were all calculated using its state at the time of the render.
첫 렌더링에서 count 는 0 입니다. 그 렌더링이 만든 onClick 함수는 count 가 0 인 세계에서 만들어졌습니다. 그 안의 count 는 끝까지 0 입니다. 그래서 세 줄은 실제로 이렇게 실행됩니다.
setCount(0 + 1);
setCount(0 + 1);
setCount(0 + 1);「다음 렌더링의 count 는 1 이다」를 세 번 말한 것입니다. 결과는 1 입니다. 문서의 한 문장이 핵심을 요약합니다.
Setting state only changes it for the next render.
비동기 코드에서도 바뀌지 않는 값
이 사진은 시간이 지나도 바뀌지 않습니다. 문서의 예를 조금 바꿔 보겠습니다.
<button onClick={() => {
setCount(count + 5);
setTimeout(() => alert(count), 3000);
}}>3초 뒤의 알림에는 5 가 아니라 0 이 뜹니다. 그 사이 화면은 이미 5 로 다시 렌더링됐지만, setTimeout 에 넘긴 함수는 count 가 0 인 렌더링에서 만들어졌기 때문입니다. 문서는 이렇게 적습니다.
A state variable's value never changes within a render, even if its event handler's code is asynchronous.
처음에는 불편해 보이지만, 저는 이 성질이 버그를 줄이는 쪽이라고 봅니다. 사용자가 「0 인 화면」을 보고 버튼을 눌렀다면, 그 클릭으로 시작된 일은 끝까지 0 을 기준으로 일관되게 동작합니다. 메시지를 보내는 도중 받는 사람 선택이 바뀌어도, 보내기 버튼을 누른 순간의 받는 사람에게 보내지는 것과 같은 원리입니다.
한 번에 모아서 처리하는 배칭
그렇다면 setCount 를 세 번 부르면 렌더링도 세 번 일어날까요. 일어나지 않습니다. 업데이트 대기열 문서에 따르면 React 는 이벤트 핸들러 안의 코드가 모두 끝날 때까지 기다렸다가 state 변경을 모아서 한 번에 처리합니다. 이것을 배칭이라고 부릅니다.
배칭 덕분에 여러 state 를 연달아 바꿔도 중간 상태가 화면에 나타나지 않고, 렌더링도 한 번만 일어납니다. 식당에서 주문을 하나씩 부엌에 보내지 않고, 손님이 다 고를 때까지 기다렸다가 한 번에 받아 가는 것과 같습니다.
이전 값으로 계산하는 업데이터 함수
정말로 3 을 올리고 싶다면 값 대신 함수를 넘깁니다.
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);c => c + 1 을 업데이터 함수라고 부릅니다. 문서의 설명대로, React 는 이 함수를 대기열에 넣었다가 다음 렌더링 때 차례로 실행하고, 각 함수에는 앞 함수가 계산한 결과를 넘깁니다. 0 에서 시작해 1, 2, 3 이 됩니다.
두 방식의 차이는 이렇게 정리됩니다.
| 호출 | 대기열에 들어가는 것 | 의미 |
|---|---|---|
setCount(count + 1) |
값 1 | 「다음 값은 1 로 바꿔라」 |
setCount(c => c + 1) |
함수 | 「대기열의 직전 값에 1 을 더해라」 |
값과 함수를 섞으면 대기열 순서대로 처리됩니다. setCount(count + 5) 다음에 setCount(c => c + 1) 이면, 5 로 바꾼 뒤 1 을 더해 6 이 됩니다.
업데이터 함수는 렌더링 중에 실행되므로 순수해야 합니다. 문서는 Strict Mode 에서 업데이터 함수를 두 번 실행해(두 번째 결과는 버림) 실수를 찾게 한다고 안내합니다. 업데이터 안에서 다른 state 를 바꾸거나 요청을 보내면 개발 환경에서 두 번 일어납니다.
업데이터를 써야 하는 자리
모든 set 호출을 업데이터로 바꿀 필요는 없습니다. 제가 보기에 업데이터가 필요한 자리는 다음 값이 이전 값에 의존하고, 그 이전 값이 이 렌더링의 사진보다 새로울 수 있을 때입니다.
한 핸들러 안에서 같은 state 를 여러 번 바꾸는 앞의 예가 첫째입니다.
둘째는 오래 사는 함수입니다. setInterval 이나 구독 콜백처럼 한 번 등록하고 계속 불리는 함수는 등록할 때의 렌더링 사진을 계속 봅니다.
useEffect(() => {
const id = setInterval(() => {
setSeconds(seconds + 1); // 늘 첫 렌더링의 seconds 를 본다
}, 1000);
return () => clearInterval(id);
}, []);의존성 배열이 비어 있으니 이 효과는 첫 렌더링에서 한 번만 실행되고, 그때의 seconds 는 0 입니다. 타이머는 매초 「다음 값은 1」만 반복하고, 화면은 1 에서 멈춥니다. setSeconds((s) => s + 1) 로 바꾸면 사진 속 값이 아니라 대기열의 최신 값으로 계산하므로 정상적으로 올라갑니다.
반대로 입력창의 값처럼 이전 값과 무관하게 새 값으로 바꾸는 경우는 setName(e.target.value) 로 충분합니다.
객체 state 를 직접 고치면 안 되는 이유
사진이라는 모델은 객체 state 에서 한 가지 규칙을 더 요구합니다.
const [form, setForm] = useState({ name: "", phone: "" });
function handleName(e) {
form.name = e.target.value; // 사진을 고쳐 그린다
setForm(form);
}이 코드는 화면이 바뀌지 않습니다. useState 문서에 따르면 새 값이 현재 state 와 Object.is 로 같으면 React 는 렌더링을 건너뜁니다. form 은 속을 고쳤어도 같은 객체라서 「바뀐 것이 없다」로 판단됩니다.
객체 state 문서는 그래서 state 에 넣은 객체를 읽기 전용으로 다루라고 합니다. 고치지 말고 새 객체로 바꿉니다.
setForm({ ...form, name: e.target.value });지난 사진은 그대로 두고 새 사진을 건네는 방식입니다. 이렇게 해야 지난 렌더링에서 만든 함수들이 보는 값도 그대로 유지되어, 앞에서 본 스냅샷의 일관성이 깨지지 않습니다.
여기까지가 확실한 부분
set 함수가 다음 렌더링에만 영향을 준다는 점, 렌더링 안에서 state 가 바뀌지 않는다는 점, 배칭과 업데이터 대기열, Object.is 비교, Strict Mode 에서 업데이터를 두 번 실행한다는 점은 react.dev 문서 기준입니다. 배칭이 이벤트 핸들러 밖(예: setTimeout 안이나 Promise 뒤)에서도 같은 방식으로 일어나는지는 React 버전에 따라 달랐던 부분이라, 오래된 버전을 쓴다면 그 버전의 문서를 확인해야 합니다.
함께 읽기
- React state 구조 설계 원칙견적함 화면에 품목 목록과 합계가 있습니다.
- React useMemo와 useCallback은 언제 써야 할까?리뷰에서 이런 코드를 자주 만납니다.
- React Error Boundary와 try/catch의 차이대시보드에 위젯이 여섯 개 있습니다. 그중 매출 차트 위젯이 서버에서 예상과 다른 모양의 데이터를 받아 렌더링 중에 오류를 냅니다.
- React useActionState와 useOptimistic으로 폼 다루기견적 요청 폼을 보내는 코드입니다.
- React 커스텀 훅은 state를 공유할까?장바구니 아이콘과 장바구니 페이지가 모두 담긴 상품 수를 보여 줘야 해서, 로직을 커스텀 훅으로 뺐다고 해 보겠습니다.