들어가며

이전 게시글에서 state는 렌더 트리의 특정 위치에 연결된 값이라고 정리했다.

그렇다면 다음 질문이 생긴다.

  • state는 실제로 어디에 저장될까?
  • useState를 여러 번 호출하면 React는 어떻게 구분할까?
  • setState를 호출하면 값은 어디에 쌓이고, 언제 반영될까?

이 글에서는 React 내부 구조인 Fiber와 Hook을 기준으로 useState가 실제로 어떻게 동작하는지 정리해본다.

참조 버전은 React 19이며, 핵심 로직은 18에서도 동일하다. 소스 기준 파일은 ReactFiberHooks.js다.


1. state는 Fiber의 어디에 저장될까?

Fiber와 memoizedState

React는 각 컴포넌트를 Fiber 노드로 관리한다. 함수 컴포넌트의 Fiber에는 memoizedState 필드가 있고, 이 필드가 Hook 연결 리스트의 시작점이다.

Fiber
├─ type
├─ key
├─ memoizedProps
└─ memoizedState  ← Hook 연결 리스트의 head

Hook 노드의 실제 구조

React 소스에서 Hook 타입은 다음과 같이 정의된다. (ReactFiberHooks.jstype Hook)

type Hook = {
  memoizedState: any; // 현재 렌더에서 확정된 state 값
  baseState: any;
  baseQueue: Update | null;
  queue: UpdateQueue; // 이 Hook에 연결된 update queue
  next: Hook | null; // 다음 Hook 노드
};

각 Hook은 자신의 state와 update를 관리하기 위해 독립적인 queue를 가진다.

Fiber의 memoizedState와 Hook의 memoizedState는 다른 값이다.

  • Fiber.memoizedState: Hook 연결 리스트의 시작 포인터
  • Hook.memoizedState: 실제 state 값

이 둘을 같은 값으로 생각하면 이후 흐름이 헷갈릴 수 있다.

baseStatebaseQueue는 concurrent rendering에서 사용된다.

React는 모든 update를 한 번에 처리하지 않고, 우선순위(lane)에 따라 일부 update를 다음 렌더로 미룰 수 있다.

  • baseState: 여기까지 계산된 state
  • baseQueue: 아직 처리되지 않은 update

Hook은 호출 순서대로 연결된다

function Component() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
}

마운트 시 Fiber의 memoizedState는 다음과 같은 구조가 된다.

useState 하나당 Hook 노드 하나가 생성되고, 호출 순서대로 연결된다. Fiber의 memoizedState는 항상 첫 번째 Hook(head)을 가리키고, 이후 Hook들은 next로 뒤에 연결된다.


2. useState는 어떻게 마운트될까?

mountState

컴포넌트가 처음 렌더될 때 mountState가 실행된다. (ReactFiberHooks.jsmountState)

function mountState(initialState) {
  const hook = mountWorkInProgressHook(); // 새 Hook 노드 생성 및 리스트에 연결
  hook.memoizedState = hook.baseState = initialState;
 
  const queue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  };
  hook.queue = queue;
 
  const dispatch = (queue.dispatch = dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ));
  return [hook.memoizedState, dispatch];
}

마운트 시에는 Hook이 생성되고, 초기 state와 queue가 설정된다.

여기서 중요한 건 dispatch가 어떻게 만들어지는지다.

dispatchSetState.bind(null, fiber, queue);

dispatchSetState는 Fiber와 queue를 인자로 받는 함수이고, bind를 통해 이 값들을 미리 고정한 새로운 함수가 만들어진다.

이렇게 만들어진 dispatch는 호출될 때마다 자신에게 연결된 queue에 update를 추가한다.

즉, setState는 state를 직접 변경하는 것이 아니라 update를 queue에 쌓는 역할을 한다.


3. setState를 호출하면 무슨 일이 일어날까?

update 객체 구조

setState(action)이 호출되면 update 객체가 생성된다. (ReactFiberHooks.jsdispatchSetState)

type Update<S, A> = {
  lane: Lane; // 업데이트 우선순위
  revertLane: Lane; // optimistic update 롤백용 (React 19)
  action: A; // 새 값(값 교체) 또는 함수(이전 state → 새 state)
  hasEagerState: boolean;
  eagerState: S | null; // 동기 렌더에서 미리 계산한 state
  next: Update<S, A>; // 다음 update (원형 연결)
};

update queue — 원형 연결 리스트

생성된 update는 해당 Hook의 queue.pending에 연결된다.

React는 queue.pending가장 마지막에 추가된 update를 가리키도록 유지한다. 그리고 그 update의 next가장 처음에 추가된 update를 가리킨다.

setCount(1), setCount(2), setCount(3)을 순서대로 호출하면:

원형 구조이기 때문에 pending만 알면 head(첫 번째 update)도 pending.next로 바로 접근할 수 있다. 이 구조 덕분에 한 번의 렌더 전에 여러 setState가 호출돼도 모두 순서대로 누적할 수 있다.

update는 언제 반영될까?

queue에 쌓인 update는 다음 렌더 시점에 처리된다.

렌더가 시작되면 updateReducerqueue.pending에 쌓인 update들을 꺼내고, pending.next(첫 번째 update)부터 순서대로 적용해 최종 state를 계산한다.

이렇게 계산된 값이 hook.memoizedState에 저장되고, 컴포넌트 함수는 그 값을 기반으로 다시 실행된다.

단계별 시각화

아래 데모에서 setCount(n => n + 1)을 두 번 호출했을 때 Fiber → Hook → UpdateQueue가 단계적으로 어떻게 변하는지 확인할 수 있다.

1 / 7 — mountState: Hook 노드 초기화
Fiber
memoizedState:Hook
alternate:...
lanes:...
Hook
memoizedState:0
baseState:0
queue.pending:null
next:null

컴포넌트가 처음 렌더되면 Hook 노드가 생성되고 초기 state가 0으로 설정된다.
queue는 비어 있는 상태(null)이며, 이후 setCount를 호출하면 이 queue에 update가 쌓이게 된다.


4. 왜 state는 스냅샷처럼 동작할까?

setCount(count + 1);
console.log(count); // 이전 값이 찍힘

Hook 구조로 다시 살펴보면 렌더가 시작될 때 updateReducer는 queue에 쌓인 update를 모두 처리해 최종 state를 계산하고, 그 값을 hook.memoizedState에 저장한다.

컴포넌트 함수는 이 memoizedState를 기준으로 실행되며, 렌더가 끝날 때까지 이 값은 변경되지 않는다.

따라서 setCount를 호출해도 현재 렌더의 값은 바뀌지 않고, 변경은 다음 렌더에서 새로운 memoizedState로 반영된다.


5. 왜 Hook은 항상 같은 순서로 호출되어야 할까?

workInProgressHook 커서

렌더 중 React는 workInProgressHook이라는 포인터로 현재 처리 중인 Hook 노드를 추적한다.
useState, useEffect 등 모든 Hook 호출은 이 포인터를 한 칸씩 앞으로 이동시킨다.

Hook 호출 순서와 연결 리스트의 노드 순서가 정확히 일치해야 React는 올바른 Hook에서 state를 읽어올 수 있다.

조건부 Hook이 문제가 되는 이유

// 첫 번째 렌더: condition = true
// 두 번째 렌더: condition = false
if (condition) {
  const [a, setA] = useState(0);
}
const [b, setB] = useState(0);
const [c, setC] = useState(0);

첫 번째 렌더에서는 Hook이 a → b → c 순서로 호출되지만, 두 번째 렌더에서는 b → c순서로 호출된다.

React는 Hook을 이름이 아니라 호출 순서로 매핑하기 때문에 두 번째 렌더에서 b는 첫 번째 Hook 자리에 있는 state를 읽고, c는 두 번째 Hook 자리에 있는 state를 읽게 된다.

그 결과 Hook과 이전 state의 매칭이 깨지게 된다.


6. 같은 값으로 setState 하면 왜 리렌더링이 안 될까?

setCount(1);
setCount(1); // 같은 값

dispatchSetState는 queue에 다른 update가 없을 때, 다음 state를 미리 계산해 현재 state와 비교한다.

// ReactFiberHooks.js — dispatchSetState 내부 (간략화)
const currentState = queue.lastRenderedState;
const eagerState = reducer(currentState, action);
if (Object.is(eagerState, currentState)) {
  // 동일하면 update를 생략 (bailout)
  return;
}

여기서 Object.is는 두 값이 같은지를 비교하는 함수다. 이 비교 결과 값이 동일하다고 판단되면 해당 update는 queue에 추가되지 않고 렌더도 생략될 수 있다.

관련 용어 살펴보기

  • eagerState : update를 queue에 추가하기 전에 미리 계산한 다음 state 값이다. queue에 다른 update가 없을 때 이 값을 이용해 현재 state와 비교할 수 있다.

  • bailout : 미리 계산한 state가 현재 state와 동일할 경우 해당 update를 처리하지 않고 건너뛰는 것을 의미한다.


마치며

React 공식 문서나 책으로 볼 때는 state가 스냅샷처럼 동작한다는 말이나 Hook이 순서로 매핑된다는 설명이 잘 와닿지 않았다. 어디에 어떤 값이 저장되고, 언제 바뀌는지가 눈에 보이지 않았기 때문이다.

코드를 직접 따라가면서 특히 헷갈렸던 건 Fiber의 memoizedState와 Hook의 memoizedState 차이, Hook의 next와 update queue의 next 차이, 그리고 setState 이후 update가 왜 바로 반영되지 않고 queue에 쌓이는지 같은 부분이었다.

이걸 한 단계씩 확인하고 나니 state가 왜 스냅샷처럼 동작하는지, 왜 Hook을 항상 같은 순서로 호출해야 하는지, 왜 같은 값으로 setState 하면 리렌더가 생략될 수 있는지가 하나의 구조 안에서 같이 이해되기 시작했다.

state를 기준으로 Fiber 구조를 따라가다 보니 이제는 useMemouseCallback도 같은 구조 위에서 어떻게 동작하는지 궁금해졌다. 다음 글에서는 이 부분을 이어서 정리해볼 예정이다.

참고