들어가며
React에서 state를 사용하다 보면 자연스럽게 이런 생각이 든다.
"state는 컴포넌트에 속한 값이고, 컴포넌트가 유지되는 한 그대로 유지되겠지?"
그러나 setState 직후 console.log를 확인해 보면 기대한 값이 아니라 이전 값이 출력된다.
그 이유를 이해하려면 state가 어디에 저장되는지부터 살펴봐야 한다.
1. state는 컴포넌트 코드 안에 없다
함수 컴포넌트는 매 렌더마다 다시 실행된다
렌더는 결국 컴포넌트 함수 호출이다. 이전 실행의 지역 변수는 사라진다.
function Counter() {
const [count, setCount] = useState(0); // 매 렌더마다 실행됨
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}함수가 매번 다시 실행되는데도 count는 0 → 1 → 2 → ... 로 유지된다. 왜일까?
useState(0)에서 0은 처음 마운트될 때만 사용되는 초기값이다. 이후 렌더에서는 React가 보관하고 있는 값을 반환한다.
state는 React 내부 구조에 있다
state는 컴포넌트 함수 안에 저장되지 않는다. React는 Fiber라는 내부 데이터 구조를 통해 컴포넌트의 state를 관리한다. 컴포넌트 함수는 그 값을 읽어오는 역할만 한다.
useState를 호출하면 React는 현재 렌더 중인 컴포넌트의 Fiber 노드에서 호출
순서를 기준으로 해당 state를 찾아 반환한다. 컴포넌트 함수가 다시 실행돼도
Fiber는 사라지지 않기 때문에 state가 유지된다.
2. setState는 값을 즉시 바꾸지 않는다
렌더 파이프라인의 흐름
setState를 호출하면 내부에서 다음 단계가 진행된다.
① 업데이트 큐에 등록 — 해당 state가 연결된 큐에 "이 값으로 업데이트해달라"는 요청을 추가한다.
② 렌더 스케줄링 — React는 업데이트를 바로 처리하지 않고, 현재 실행 중인 작업과 우선순위를 고려해 렌더 시점을 예약한다.
③ Render Phase — 컴포넌트 함수를 다시 호출해 새 JSX 트리를 계산한다.
④ Reconciliation — 이전 트리와 새 트리를 비교해 변경점을 계산한다.
⑤ Commit Phase — 변경점을 실제 DOM에 반영한다.
state는 '현재 렌더의 스냅샷'이다
렌더가 시작되면 그 렌더에서 사용하는 state 값은 하나의 스냅샷으로 고정된다.
렌더 중에 setState를 호출해도 현재 렌더 안에서 그 값은 바뀌지 않는다.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // 0 (현재 렌더의 스냅샷)
}
return <button onClick={handleClick}>{count}</button>;
}setCount(count + 1)을 호출한 직후에도 count는 여전히 0이다.이 렌더에서는 count = 0으로 고정되어 있기 때문이다.
새 값(1)은 다음 렌더에서 반영된다.
이 특성 때문에 이벤트 핸들러 안에서 같은 방식으로 여러 번 호출해도 값이 누적되지 않는다.
function handleClick() {
setCount(count + 1); // setCount(0 + 1)
setCount(count + 1); // setCount(0 + 1)
setCount(count + 1); // setCount(0 + 1) — 결과는 3이 아니라 1
}누적 계산이 필요하다면 setCount(c => c + 1) 같은 함수형 업데이트를 써야 한다.
같은 이벤트 루프 안의 여러 setState 호출은 React가 묶어서 한 번에
처리한다(batching). 중간 상태가 화면에 노출되지 않고 한 번의 렌더로 최종
상태가 반영된다.
3. state는 트리의 '위치'에 연결된다
그렇다면 React는 어떤 컴포넌트의 어떤 state인지 어떻게 알까?
렌더 트리와 위치
React는 JSX를 렌더링할 때 내부적으로 트리를 구성한다. 각 컴포넌트는 트리의 특정 위치에 자리 잡고, React는 이 위치를 기준으로 state를 관리한다.
function App() {
return (
<div>
<Counter /> {/* 위치 A */}
<Counter /> {/* 위치 B */}
</div>
);
}두 <Counter />는 같은 컴포넌트지만 트리에서 서로 다른 위치에 있다. React는 각 위치마다 별도의 state를 유지하므로 Counter는 각자 독립적인 count를 가진다.
state가 유지되는 조건
state가 유지되려면 두 가지가 동일해야 한다.
- 같은 위치
- 같은 타입
이 조건이 만족되면 React는 이전 state를 이어서 사용한다.
function App() {
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
</div>
);
}isFancy를 토글해도 같은 위치에 Counter 타입이 렌더링되어 state는 유지된다.
React에서 "같은 컴포넌트"란 이름이 아니라, 트리의 같은 위치에 같은 타입이 나타나는 것을 의미한다. props가 달라도 위치와 타입이 같으면 state는 유지된다.
state 초기화되는 경우
다음 조건 중 하나라도 해당되면 React는 새로운 컴포넌트로 판단하고 state를 초기화한다.
1. 타입이 바뀌는 경우
function App() {
const [isPaused, setIsPaused] = useState(false);
return <div>{isPaused ? <p>Paused</p> : <Counter />}</div>;
}isPaused가 바뀌면 같은 위치에서 Counter ↔ p 태그가 교체된다.
React는 Counter와 p를 다른 타입으로 인식한다. 타입이 다르면 해당 위치의 기존 컴포넌트를 제거하고, 새로운 컴포넌트를 생성한다. 이 과정에서 Counter의 count는 초기화된다.
2. 컴포넌트가 제거되는 경우
function App() {
const [show, setShow] = useState(true);
return (
<div>
{show && <Counter />}
<button onClick={() => setShow(!show)}>Toggle</button>
</div>
);
}컴포넌트가 트리에서 사라지면 해당 위치도 함께 사라지고, state도 제거된다. 다시 나타날 때는 새로운 state로 시작한다.
숫자를 올린 후 스타일을 바꿔보세요. 같은 위치에 같은 타입이므로 count가 유지됩니다.
4. key로 state를 의도적으로 초기화하기
위치와 타입이 같아도 state를 리셋해야 할 때가 있다. 예를 들어 채팅 앱에서 대화 상대가 바뀌면 입력창 내용을 초기화해야 한다.
function Chat({ userId }) {
return <ChatInput key={userId} />;
}key는 React가 컴포넌트를 구분하기 위한 identity 역할을 한다.
같은 위치에 있어도 key가 다르면 React는 해당 컴포넌트를 이전 것과 다른 것으로 판단하고, 기존 컴포넌트를 제거한 뒤 새로운 컴포넌트를 생성한다. 이 과정에서 state는 초기화된다.
key="A" → key="A" : 같은 컴포넌트 → state 유지
key="A" → key="B" : 다른 컴포넌트 → state 초기화
key에 index를 사용하는 것은 피해야 한다.
index는 항목의 "위치"일 뿐 "정체성"이 아니기 때문에 리스트의 순서가 바뀌면 동일한 데이터가 다른 key를 갖게 된다.
이 경우 React는 컴포넌트를 잘못 매칭하게 되고, state가 의도하지 않게 유지되거나 초기화될 수 있다.
메시지를 입력한 후 대화 상대를 바꿔보세요. key 사용 여부에 따라 입력 내용이 유지되거나 초기화됩니다.
<ChatInput key="Alice" />5. 컴포넌트를 중첩 정의하면 안 되는 이유
문제 상황
function Parent() {
const [count, setCount] = useState(0);
// ⚠️ 렌더마다 새로운 함수 객체가 만들어진다
function Child() {
const [text, setText] = useState("");
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
return (
<div>
<button onClick={() => setCount(count + 1)}>+{count}</button>
<Child />
</div>
);
}왜 문제인가?
state는 같은 위치 + 같은 타입일 때 유지된다.
하지만 Child는 Parent가 렌더될 때마다 새로운 함수로 생성된다.
첫 렌더: <Child> 타입 → function Child() { ... } (참조 A)
두 번째 렌더: <Child> 타입 → function Child() { ... } (참조 B)
참조 A ≠ 참조 B → React는 다른 타입으로 인식
따라서 Parent가 리렌더될 때마다 Child는 새로 마운트되고, text state는 매번 초기화된다.
해결 방법
컴포넌트는 항상 최상위 레벨에 정의한다.
// ✅ 최상위에 정의
function Child() {
const [text, setText] = useState("");
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>+{count}</button>
<Child />
</div>
);
}이렇게 하면 모든 렌더에서 동일한 함수 참조를 유지할 수 있고, React는 같은 타입으로 인식해 state를 유지한다.
마치며
state를 컴포넌트 내부의 값이 아니라 렌더 트리의 특정 위치에 연결된 값으로 이해하는 것이 중요하다.
이는 의도하지 않은 state 초기화나 유지와 같은 버그를 줄일 수 있고, 컴포넌트 구조를 변경할 때도 state의 동작을 예측할 수 있다.
또한 key를 통해 state를 의도적으로 초기화하거나 잘못된 state 연결을 방지하는 등 상태를 더 명확하게 제어할 수 있다.
다음 글에서는 이러한 동작이 실제로 어떻게 구현되어 있는지 Fiber 구조를 중심으로 살펴본다.
참고
- State: A Component's Memory — state가 일반 변수와 다른 이유, useState의 동작
- Render and Commit — 렌더 파이프라인 3단계 (trigger → render → commit)
- State as a Snapshot — 렌더마다 고정되는 스냅샷 개념, 이벤트 핸들러 안의 state 동작
- Preserving and Resetting State — 트리 위치 기반 state 보존, key를 이용한 초기화