
setState가 즉시 반영 안 되는 이유 (React Batching)
React의 setState가 비동기로 동작해서 겪었던 삽질과 해결 과정. 사학과 출신 창업자가 겪은 React 상태 관리의 미스터리를 풉니다.

React의 setState가 비동기로 동작해서 겪었던 삽질과 해결 과정. 사학과 출신 창업자가 겪은 React 상태 관리의 미스터리를 풉니다.
분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

서버에서 잘 오던 데이터가 갑자기 앱을 죽입니다. 'type Null is not a subtype of type String' 에러의 원인과, 안전한 JSON 파싱을 위한 Null Safety 전략을 정리해봤습니다.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

저는 대학에서 역사를 전공했습니다. 졸업 후에는 7년간 영업과 기획 일을 했죠. 그러다 내 아이디어로 창업을 결심했고, "개발자 구할 돈이 없으면 내가 한다"는 패기로 코딩을 독학하기 시작했습니다.
HTML/CSS는 즐거웠습니다. 화면이 바로바로 바뀌니까요. 하지만 React(리액트)를 만나고 나서 제 자신감은 바닥을 쳤습니다. 가장 저를 괴롭혔던 건 복잡한 알고리즘이 아니라, 아주 사소한 "숫자 더하기"였습니다.
쇼핑몰의 '장바구니 수량 추가' 버튼을 만들고 있었습니다. 버튼을 누르면 수량이 1 증가하고, 로그를 찍어서 확인하는 간단한 코드였습니다.
/* 내 상식으로 짠 코드 */
const handleClick = () => {
setCount(count + 1); // 1. 수량을 1 늘린다.
console.log(count); // 2. 늘어난 수량을 확인한다. (기대값: 1)
};
그런데 콘솔에는 0이 찍혔습니다. "어라? 컴퓨터가 느린가?" 한 번 더 눌렀습니다. 화면은 2가 됐는데 콘솔은 1이 찍혔습니다.
마치 제 코드가 과거를 살고 있는 것 같았습니다. 이 이해할 수 없는 현상 때문에 저는 3일 동안 밤을 샜습니다.
제가 가진 오개념은 이것이었습니다.
"변수에 값을 할당하면(=), 즉시 바뀐다."
일반적인 자바스크립트는 그렇습니다.
let a = 0;
a = 1;
console.log(a); // 1
하지만 React의 useState에서 나온 setCount는 다릅니다.
이것은 "변수 할당"이 아니라 "React에게 상태 변경을 요청(Request)"하는 함수입니다.
요청만 해놓고 바로 다음 줄(console.log)을 실행하니까, 아직 변경되지 않은 옛날 값을 찍는 것이었습니다.
이 현상을 이해하게 해 준 비유는 "우체통"입니다.
여러분이 편지를 써서 우체통에 넣었다고(setCount) 칩시다.
그 즉시 우체부 아저씨가 나타나서 편지를 배달해주나요? 아닙니다.
우체부 아저씨는 하루에 한 번, 우체통이 꽉 차면 수거해갑니다.
React도 마찬가지입니다.
setState를 호출할 때마다 매번 화면을 새로 그리면(리렌더링) 성능이 끔찍하게 느려질 겁니다.
그래서 React는 "짧은 시간 동안 들어온 변경 요청을 모아뒀다가, 한꺼번에 처리"합니다.
이것을 전문 용어로 배칭(Batching)이라고 합니다.
const handleClick = () => {
setCount(count + 1); // "1 더해주세요" (요청 1)
setFlag(true); // "깃발 들어주세요" (요청 2)
setName("Ironman"); // "이름 바꿔주세요" (요청 3)
// React: "알았어, 좀만 기다려. 할 일 다 끝나면 한 번에 처리해줄게."
};
덕분에 리렌더링은 딱 1번만 일어납니다. 효율적이죠?
하지만 그 대가로 우리는 setState 직후에 바뀐 값을 바로 확인할 수 없게 된 것입니다.
더 황당한 문제도 있었습니다.
const handleTripleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
직관적으로는 3이 더해져야 할 것 같지만, 결과는 1만 증가합니다.
왜냐고요? 이 함수가 실행되는 시점(스냅샷)에서 count는 0이기 때문입니다.
setCount(0 + 1) -> "0을 1로 만들어줘"setCount(0 + 1) -> "0을 1로 만들어줘"setCount(0 + 1) -> "0을 1로 만들어줘"React는 착실하게 "1로 만들어달라"는 요청만 3번 받은 셈이니, 결과는 1입니다.
이 문제를 해결하려면 "현재 값 말고, 직전 값(Previous Value)을 써줘!"라고 말해야 합니다.
const handleTripleClick = () => {
setCount(prev => prev + 1); // "직전 값에 1 더해줘" (0 -> 1)
setCount(prev => prev + 1); // "직전 값에 1 더해줘" (1 -> 2)
setCount(prev => prev + 1); // "직전 값에 1 더해줘" (2 -> 3)
};
이제 React는 명령을 순서대로 수행해서 정확히 3을 만들어냅니다. "이전 값에 의존하는 상태 변경"을 할 때는 무조건 함수형 업데이트를 쓰는 게 안전합니다.
예전(React 17 이하)에는 배칭이 반쪽짜리였습니다.
setTimeout이나 fetch 같은 비동기 함수 안에서는 배칭이 안 됐거든요.
하지만 React 18부터는 어디서든 배칭이 작동합니다!
/* React 18: 자동 배칭 */
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 옛날엔 리렌더링 2번 됐지만, 이젠 1번만 됨! (성능 대폭 향상)
}, 1000);
이 덕분에 불필요한 렌더링을 신경 쓸 필요가 많이 줄었습니다. React 팀이 우리 몰래 성능 최적화를 엄청나게 해주고 있는 거죠.
처음에는 "setState 하나 제대로 못 만드는 불편한 라이브러리"라고 욕했습니다.
하지만 그 불편함 뒤에는 "어떻게 하면 웹사이트를 더 빠르고 효율적으로 그릴까?"라는 React 팀의 깊은 고민이 숨어 있었습니다.
setState는 비동기다. (우체통이다)useEffect를 써라. (배달 완료 문자)이 원리를 깨닫고 나니, React가 적으로 느껴지는 게 아니라 든든한 파트너로 보이기 시작했습니다. 비전공자인 저도 이해했으니, 여러분도 할 수 있습니다.
I majored in History in college. Initially, I worked in sales and planning for 7 years. Then I decided to start my own business. With no money to hire developers, I decided to learn coding myself with pure grit.
HTML/CSS was fun. Changes were instant. But when I met React, my confidence crashed. It wasn't complex algorithms that broke me. It was simple "Addition".
I was building an "Add Quantity" button for my shopping cart. A simple code to increment a number and log it.
/* My Logic */
const handleClick = () => {
setCount(count + 1); // 1. Add 1
console.log(count); // 2. Check the value. (Expected: 1)
};
But the console printed 0. "Is my computer slow?" I clicked again. Screen showed 2, console printed 1.
It felt like my code was living in the past. I stayed up for 3 nights trying to understand this ghost.
My misconception was strict:
"Assigning a value to a variable (=) updates it immediately."
In standard JavaScript, this is true.
let a = 0;
a = 1;
console.log(a); // 1
But React's setCount is different.
It is not an assignment. It is a "Request to React to update state."
Since I made the request and immediately ran the next line (console.log), it printed the old value because the update hadn't happened yet.
The analogy that saved me was the "Mailbox".
Imagine setState is dropping a letter in a mailbox.
Does the mailman pick it up instantly? No.
He collects them once a day when the box is full.
React is the same.
If it re-rendered the screen every single time setState was called, performance would be terrible.
So React "collects requests over a short period and processes them at once."
Running this updates in a single go is called Batching.
const handleClick = () => {
setCount(count + 1); // "Add 1" (Request 1)
setFlag(true); // "Raise Flag" (Request 2)
setName("Ironman"); // "Change Name" (Request 3)
// React: "Got it. Wait a bit. I'll do it all together."
};
Thanks to this, re-rendering happens only once. Efficient, right?
But the price is that we cannot verify the updated value immediately after setState.
Here is a crazier problem.
const handleTripleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
Intuitively, it should add 3. But it adds only 1.
Why? Because at the moment this function runs (snapshot), count is 0.
setCount(0 + 1) -> "Make 0 into 1"setCount(0 + 1) -> "Make 0 into 1"setCount(0 + 1) -> "Make 0 into 1"React faithfully received "Make it 1" three times. So the result is 1.
To fix this, we must say "Use the Previous Value, not the current one!"
const handleTripleClick = () => {
setCount(prev => prev + 1); // "Add 1 to previous" (0 -> 1)
setCount(prev => prev + 1); // "Add 1 to previous" (1 -> 2)
setCount(prev => prev + 1); // "Add 1 to previous" (2 -> 3)
};
Now React executes commands in order to get exactly 3. Always use Functional Updates when "The new state depends on the old state."
In the past (React 17 and below), batching was inconsistent.
It didn't work inside setTimeout or fetch.
But in React 18, Automatic Batching works everywhere!
/* React 18: Automatic Batching */
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// Used to re-render twice. Now only once! (Huge perf boost)
}, 1000);
We worry less about unnecessary renders now. The React team is optimizing performance behind our backs.
The most important takeaway for React beginners is this: State is not a variable that changes in place. It's a snapshot of the UI at a specific point in time.
When you call setCount(count + 1), you are not changing the number 0 to 1 inside the current render.
You are telling React: "For the next render, I want the UI to reflect that count is 1."
The current render keeps seeing 0 forever. It will never see 1.
Only the next render (function call) will receive 1 as the count prop.
This is why alert(count) inside a setTimeout will still show 0 even after 3 seconds if it was defined in the initial render. This is called a Closure, which captures the variable at the moment of creation.
Interviewer: "What logs will appear in the console?"
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
setCount(count + 2);
setCount(count + 3);
console.log(count);
};
Answer:
0 (because state updates are async/batched).3 (not 6!).
count is 0 in all three calls.setCount(0 + 1)setCount(0 + 2)setCount(0 + 3) -> This last one wins.If you want the answer to be 6, you must use functional updates: setCount(prev => prev + 1).
flushSync?Sometimes, very rarely, you need the DOM to update immediately (e.g., to measure an element right after state change).
React provides flushSync for this.
import { flushSync } from 'react-dom';
const handleClick = () => {
flushSync(() => {
setCount(1);
});
// DOM is updated HERE.
console.log(divRef.current.innerText); // "1"
};
Warning: flushSync kills performance because it forces a synchronous re-render. Use it only as a last resort (e.g., for focus management or complex animations).