useState는 어떻게 값을 기억하는가 (리액트 코드로 Fiber 구조와 렌더링 살펴보기)
TMI) 네이버랩스 인턴을 마치고 10일간 미국에서 쉬다왔다. 요즈음엔 개발 공부하는 것에 재미를 다시 붙이고 있다. 어쩌다보니 리액트 소스코드를 뜯어보는데에 재미를 붙여서, 동시에 이것저것 찾아보다보니 정보의 레이어가 쌓이는 장점도 있지만 그만큼 더 헷깔리는 것들이 생기는 것 같기도 하다. (특히 다양한 자료에서 용어를 혼용하는 경우가 많다..) 정보의 홍수와 AI로 인한 FOMO 속에서 혼란스러운 개발자 생태계 안에서 다음 스텝을 준비하는 입장에서, 리액트 소스코드를 뜯어본 경험은 안티프래질하다고 볼 수 있지 않을까 ..? 라는 생각이다.
Fiber
React는 컴포넌트 하나마다 Fiber 객체를 만든다. state는 React 내부의 Fiber 객체에 저장되는데, state와 관련된 필드만 보면
Fiber {
memoizedState → Hook 링크드 리스트 (useState 값들이 여기 저장됨)
memoizedProps → 가장 최근에 반영된 props
pendingProps → 이번 렌더링에서 새로 받은 props
stateNode → 실제 DOM 노드
}
Fiber는 컴포넌트와 1:1 대응한다.
<Board> → Board Fiber
<Square /> → Square Fiber
<Square /> → Square Fiber
...
Hook Linked List
useState를 여러 개 쓰면 React 내부에서 Hook 객체들이 Linked List로 연결된다.
function Board() {
const [squares] = useState(Array(9).fill(null)); // Hook 1
const [isX] = useState(true); // Hook 2
const [count] = useState(0); // Hook 3
}
Fiber.memoizedState
↓
Hook { memoizedState: [null,...], next → }
Hook { memoizedState: true, next → }
Hook { memoizedState: 0, next → null }
여기서 "Hook 객체"는 우리가 호출하는 useState 함수가 아니라, 그 값을 저장하는 내부 데이터 구조다.
React는 Hook을 이름이 아닌 호출 순서로만 구분한다. 변수명 squares, isX, count는 React 내부에서 보이지 않는다.
조건문 안에 Hook을 쓰면 안 되는 이유
// ❌
function Board() {
const [squares] = useState(...); // Hook 1
if (something) {
const [isX] = useState(...); // 어떨 땐 Hook 2, 어떨 땐 없음
}
const [count] = useState(...); // Hook 2 또는 3으로 매칭됨 → 엉뚱한 값 반환
}
순서가 바뀌면 링크드 리스트 탐색 결과가 달라져서 잘못된 값이 반환된다.
Rules of Hooks
- 최상위에서만 호출 (조건문, 반복문, 중첩 함수 안에 넣지 말 것)
- React 함수 컴포넌트 안에서만 호출
단, 컴포넌트 단위 조건부 렌더링은 문제없다. 컴포넌트가 마운트/언마운트되면 그 Fiber 자체가 생겼다 사라지므로 다른 컴포넌트의 Hook 순서에 영향이 없다.
mountState vs updateState
React는 최초 렌더링인지 재렌더링인지에 따라 다른 함수를 실행한다.
// 최초 렌더링
function mountState(initialState) {
const hook = mountWorkInProgressHook(); // 새 Hook 객체 생성
hook.memoizedState = initialState; // 초기값 저장
return [hook.memoizedState, dispatch];
}
// 재렌더링
function updateState(initialState) {
const hook = updateWorkInProgressHook(); // 기존 Hook 재사용
// initialState는 무시됨
const newState = processUpdateQueue(hook); // 큐 처리 → 최신값
hook.memoizedState = newState;
return [hook.memoizedState, dispatch];
}
dispatcher가 렌더링 단계에 따라 교체된다.
ReactCurrentDispatcher.current =
isMount ? HooksDispatcherOnMount : HooksDispatcherOnUpdate;
업데이트 큐
setSquares(nextSquares)를 호출하면 React는 즉시 값을 바꾸지 않는다. 업데이트를 큐에 쌓고 리렌더링을 예약한다.
function dispatchSetState(fiber, queue, action) {
const update = { action, next: null };
enqueueUpdate(fiber, queue, update); // 큐에 등록
scheduleUpdateOnFiber(fiber); // 리렌더링 예약
}
Hook {
memoizedState: [null, null, ...] ← 아직 이전 값
queue: Update { action: ['X', null, ...] }
}
재렌더링 시 processUpdateQueue가 큐를 처리해서 최신값을 계산하고 memoizedState에 반영한다. 여러 번의 setState를 한 번에 처리하는 배칭(batching)이 이 구조 덕분에 가능하다.
Element vs Fiber
| React Element | Fiber | |
| 정체 | JSX가 변환된 순수 객체 | 작업 단위 + 상태 저장소 |
| 생명주기 | 매 렌더링마다 새로 생성, 버려짐 | 재사용, 오래 유지됨 |
| 역할 | "이런 컴포넌트를 그려줘" 설명서 | 실제 state, props, DOM을 들고 있음 |
재렌더링마다 새 Element 트리가 만들어지면, React는 기존 Fiber 트리와 비교한다. 이게 재조정(Reconciliation) 이다.
같은 자리 + 같은 타입 → Fiber 재사용, memoizedProps 업데이트
같은 자리 + 다른 타입 → 기존 Fiber 버리고 새로 생성
자리가 없어짐 → Fiber 소멸 (언마운트)