React에서 상태를 업데이트하면 즉시 화면이 바뀌는 것처럼 보이지만, 내부에서는 정해진 순서대로 여러 단계를 거칩니다. 이 흐름을 이해하면 상태 업데이트 직후 값을 읽을 때 겪는 혼란, 불필요한 리렌더링 등의 문제를 예방할 수 있습니다.
setState를 호출한다고 해서 즉시 상태가 바뀌지 않습니다. React는 다음 순서로 동작합니다.
setState 호출
↓
React가 업데이트 예약 (큐에 등록)
↓
현재 이벤트 핸들러 실행 완료 대기
↓
Batch Update: 모인 업데이트를 한 번에 처리
↓
새로운 state로 컴포넌트 함수 재호출 (렌더링)
↓
Virtual DOM 비교 (Reconciliation)
↓
실제 DOM 업데이트 (커밋)
↓
useEffect 실행
같은 이벤트 핸들러 안에서 setState를 여러 번 호출하더라도 React는 각 호출마다 즉시 리렌더링하지 않습니다. 모든 호출을 모아 한 번만 리렌더링합니다. 이를 Batch Update라고 합니다.
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 큐에 등록
setCount(count + 1); // 큐에 등록
setCount(count + 1); // 큐에 등록
// 세 번 호출했지만 리렌더링은 한 번만 발생
// count는 모두 동일한 스냅샷 값(0)을 참조하므로 결과는 1
};
return <button onClick={handleClick}>{count}</button>;
}
누적 업데이트가 필요하다면 함수형 업데이트를 사용합니다. 함수형 업데이트는 현재 큐의 최신 state를 인자로 받으므로 여러 번 호출해도 올바르게 누적됩니다.
const handleClick = () => {
setCount((prev) => prev + 1); // prev = 0, 결과 1
setCount((prev) => prev + 1); // prev = 1, 결과 2
setCount((prev) => prev + 1); // prev = 2, 결과 3
// 최종 결과: 3
};
React 18부터는 setTimeout, Promise 콜백 등 비동기 상황에서도 자동으로 Batch Update가 적용됩니다. React 17 이하에서는 비동기 콜백 안의 setState는 각각 별도의 리렌더링을 발생시켰습니다.
setState를 호출한 직후 같은 함수 안에서 해당 state를 읽으면 아직 이전 값입니다. 업데이트된 값은 다음 렌더링에서 읽을 수 있습니다.
const handleClick = () => {
setCount(count + 1);
console.log(count); // 업데이트 전의 값이 출력됨
};
업데이트된 값을 기반으로 추가 작업을 해야 한다면, useEffect의 의존성 배열에 해당 state를 넣어 업데이트 이후 실행되도록 처리합니다.
각 렌더링은 해당 시점의 state 스냅샷을 가집니다. 이벤트 핸들러는 자신이 생성된 렌더링의 state 값을 캡처하며, 이후 state가 변경되어도 이미 실행 중인 핸들러 안의 값은 바뀌지 않습니다.
function Timer() {
const [message, setMessage] = useState("초기값");
const handleSend = () => {
setTimeout(() => {
// 이 시점에 message가 바뀌었어도
// 핸들러가 생성될 당시의 message 값을 사용
console.log(message);
}, 3000);
};
return (
<>
<input onChange={(e) => setMessage(e.target.value)} value={message} />
<button onClick={handleSend}>3초 후 출력</button>
</>
);
}
이 스냅샷 특성은 예측 가능한 렌더링을 보장하는 핵심 원리입니다. 최신 값이 필요하다면 useRef를 활용하여 렌더링에 묶이지 않는 값을 직접 참조할 수 있습니다.