PROGRAMMING/React

useState는 어떻게 작동할까? (2)

J1W0N 2026. 4. 6. 23:40
728x90
- 본 게시글은 React 19.2.4 버전을 기준으로 작성되어있습니다.
- 틀린 부분이 있을 수 있습니다. 댓글로 알려주시면 감사하겠습니다 : )
- Github : https://github.com/facebook/react
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={handleClick}>
        증가
      </button>
    </div>
  );
}

export default Counter;

React의 useState는 컴포넌트의 상태를 저장하고, 상태가 바뀌면 다시 렌더링하도록 만드는 훅입니다. React를 사용하면 거의 필수적으로 사용되는 훅입니다.

useState는 어떻게 작동할까? - 2편

이전 시간에서 useState 훅을 호출하면 내부적으로 Dispatcher를 할당 받는 다는 것을 알았다. 즉 useState의 실제 구현은 Dispatcher에 있다는 것을 알 수 있었다.

이번 시간에는 mount, update 이벤트에 따라 useState가 어떻게 작동되는지 코드를 살펴보며 이해해볼 것이다.

React Fiber Reconciler

코드를 분석하기 전에 React 16부터 도입된 핵심 아키텍쳐인 Fiber를 알아야한다.

이전에 ReconcilerVDOM을 활용해 변경 사항을 비교하고 Renderer가 실제 DOM에 최소한의 변경사항만 반영하는 핵심 패키지라고 했었다.

Stack Reconciler

React 16 이전에는 Stack Reconciler를 사용했다고 한다. 이 Stack Reconcilerstateprops가 변경됨에 따라 발생하는 Re-Rendering을 아래의 방법을 통해 O(n)의 복잡도로 해결했다고 한다.

  • 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
    • div -> span 처럼 노드의 타입이 변경되면, 해당 노드는 완전히 새로 그린다. 즉 해당 노드의 자식 노드는 볼 필요도 없어진다.
  • 개발자가 key prop을 통해 여러 노드의 렌더링 사이에서 어떤 자식 노드가 렌더링 되지 말아야하는지 확인한다.
    • 고유식별자인 key를 통해 필요한 부분만 빠르게 매칭해 업데이트가 가능하다.

Fiber 도입 전에는 이렇게 트리의 root부터 완전히 새로 그리거나 DFS를 통해 모든 React 엘리먼트들을 재귀적으로 탐색해 render()를 호출하여 만들어진 트리의 변경 사항을 확인하고 업데이트가 필요한 경우를 계속해서 파악해 나갔다고 한다.

Fiber Reconciler

위에서 보았던 Stack Reconciler는 재귀적으로 트리의 모든 노드를 탐색해 render()를 호출하며 콜 스택의 크기가 계속 커진다는 문제점이 있었다. 따라서 트리가 큰 경우에는 프레임 드랍도 발생할 수 있다.

Fiber Reconciler는 전체 화면을 새로 그리는 대신, 이전 프레임에서 변경된 부분만 계산하여 렌더링하는 증분 렌더링(Incremental Rendering)을 가능하게 한다.

즉, 작업을 쪼개서 변경된 부분을 계산하는데 이때, 컴포넌트 하나당 Fiber라는 노드를 만들어 관리한다. 이 Fiber 노드는 Linked List로 관리 되기에 Fiber Reconciler가 지정한 우선순위를 기준으로 실행, 중단, 재개하여 변경 사항을 계산할 수 있다. Stack Reconciler와 다르게 중단과 재개가 가능하다는 것이다. 낮은 우선 순위의 작업을 계산하던 도중 사용자 입력이 들어오면 현재 처리중인 작업을 중단하고 높은 우선 순위를 가진 작업을 먼저 계산하게 되는 것이다.

이어서..

앞의 Reconciler를 이해했다면 useStateDispatcherFiber Reconciler가 어떻게 렌더링에 관여하는지 코드로 분석해보자.

Hook을 주입하기 위해서는 packages/react-reconciler/src/ReactFiberHooks.jsrenderWithHooks를 호출해야한다. 해당 코드를 살펴보자.

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;
  ...
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.lanes = NoLanes;

  ...

  ReactSharedInternals.H =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;

  ...

  let children = __DEV__
    ? callComponentInDEV(Component, props, secondArg)
    : Component(props, secondArg);


}

우선 현재 작업 중인 Fiber 노드를 currentlyRenderingFiber에 할당한다. 이후 memoizedStateupdateQueuenull로 초기화한다. 이는 단순 초기화가 아니라, 이번 렌더에서 새로운 Hook linked list를 구성하기 위한 준비 과정이다. 이후 익숙한 구조를 만날 수 있다.

  ReactSharedInternals.H =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;

ReactSharedInternals.HDispatcher를 주입하는 코드를 확인할 수 있다. 즉 currentnull이라 최초로 렌더 되거나, memoizedStatenull이라 Hook이 없는 컴포넌트의 경우 HooksDispatcherOnMount 를 주입하고 아닌 경우 HooksDispatcherOnUpdate를 주입하는 것을 알 수 있다.

const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  use,
  ...
  useState: mountState,
  ...
  ..
  .
};

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  use,
  ...
  useState: updateState,
  ...
  ..
  .
};

HooksDispatcherOnMountHooksDispatcherOnUpdate에서 useState는 각각 mountStateupdateState를 할당시켜준다.

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

mountState의 모습은 다음과 같다. 가장먼저 mountStateImpl를 호출하여 hook을 초기화하는 것을 확인할 수 있다.

mountStateImpl함수는 다음처럼 정의 되어있다.

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
    initialState = initialStateInitializer();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

mountWorkInProgressHook를 호출해 hook을 초기화하고, initialStatefunction일 경우 initialStateInitializer에 해당 함수를 지정한 후 호출하여 initialState를 다시 값의 형태로 저장한다.

mountWorkInProgressHook 은 아래와 같이 동작한다.

export type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

Hook 객체를 생성한 뒤, 현재 렌더링 중인 Fiber에 연결된다. 이 객체는 useState가 호출되는 순서대로 linked list 형태로 관리된다. 첫 번째 HookcurrentlyRenderingFiber.memoizedState에 저장되고, 이후 Hook들은 workInProgressHook.next를 통해 연결된다.

다시 돌아와 mountWorkInProgressHook로 초기화된 hookhook.memoizedState = hook.baseState = initialState;를 통해 hookmemoizedStatebaseStateinitialState로 초기화한다.

// mountState

const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];

다시 mountState를 살펴보자. state update를 관리하기 위한 queue를 생성하여 hook.queue에 할당한다. 이 queuestate 값을 직접 저장하는 것이 아니라, state를 어떻게 변경할지에 대한 update들을 저장한다.

dispatchSetStatecurrentlyRenderingFiberqueue를 바인딩하여 dispatch 함수를 생성한다. 이 함수는 특정 Fiber와 특정 Hook에 귀속된 상태를 업데이트 함수로, 이후 setState 호출 시 어떤 컴포넌트의 어떤 상태를 업데이트해야 하는지 식별하는 역할을 한다. 이후 state update가 일어날때 해당 dispatch가 호출되도록 설정한 후 hook의 초기 상태와 업데이트 functiondispatchreturn 한다.

 

설명이 너무 장황한데 한 줄로 요약하자면 "특정 컴포넌트의 특정 상태를 식별해 업데이트할 수 있는 함수(Dispatch)를 만드는 과정"이라고 볼 수 있다.

 

dispatch 함수는 이후 호출 시 새로운 updatequeue에 추가하고, 해당 Fiber의 리렌더링을 스케줄링하는 역할을 한다.

 

따라서 useState는 업데이트 함수 호출 시 state를 직접 바꾸는게 아니라 queueupdate를 넣고, 다음 render에서 계산한다는 것을 알 수 있다.

 

다음 글은 setState가 어떻게 리렌더링을 발생시키는지를 확인해 볼 것이다.

 

참고한 블로그 : https://youngju-js.tistory.com/76