React - useState 자세히 알아보기

박상준

2025년 02월 10일

1

React

useState와 더 친해지기

저번 포스팅에서 useState를 직접 구현해보는 과정을 거쳐봤다. 하지만 실제 리액트에서는 비슷한 동작 메커니즘을 가지긴 하지만 로직 자체가 다르다. 그리고 실제 hook을 주입하는 과정도 다르기 때문에 리액트 소스코드를 하나씩 찾아가면서 알아보겠다.

참고 자료

useState는 어디에서 오는가?

우리는 컴포넌트에서 useState를 입력하고 마우스 몇번 딸깍하면 useState를 사용할 수 있다. 하지만 이게 어디에서 온 것이고, 어떻게 관리되고 있는지 알아볼 필요가 있다. 왜냐하면 그 경로를 알아야 useState의 구현체를 알수 있기 때문이다. 당연히 우리가 create-react-app을 통해 생성한 리액트 파일 내부에서는 찾을 수 없다. 그렇기 때문에 깃허브 소스 코드를 들여다 볼수 밖에 없는 것이다.

// packages/react/src/React.js
import { useRef, useState} from './ReactHooks';
import ReactSharedInternals from './ReactSharedInternals';
export {
  useRef,
  useState,
  ReactSharedInternals as __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
...
}

우선 우리가 사용하는 hook은 이곳에서 export되어 사용된다. 그리고 이 hook들은 ReactHooks파일에서 가져온다.

ReactSharedInternals : hook에 의존성 주입을 위한 중간 과정이다. 뒤에서 용도를 다시 다루겠다.

// packages/react/src/ReactHooks.js
function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
...
}

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

그리고 ReactHooks에 가서 보면 useState는 dispatcher의 메소드이다. 이 dispatcherReactCurrentDispatcher.current에서 가져오는 것을 알수가 있다. 조금더 깊숙히 들어가보겠다.

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher),
};

뭔가 예상에는 useState같은 hook들의 구현 코드가 들어있는줄 알았는데 아니였다. 단순히 객체만 덩그러니 있는 것을 보아 다른 외부에서 주입되는 것이 확실하다.

우선 리액트의 동작 방식을 생각해보자. 리액트는 React.createElement를 통해 생성된 정적인 요소들을 reconciler를 통해서 비교하고 업데이트한다. 그렇기 때문에 useStateuseEffect같은 hook들은 React.createElement를 통해서 관리되는 것이 아니다. reconciler를 통해서 동작하는 것이 hook이고, 결론적으로 hook은 reconciler를 통해 얻는 다는 것이다.

하지만 reconciler의 코드를 찾아봐도 직접적인 로직을 확인할 수는 없다. 이를 통해서 알수 있는 것은 hook의 핵심 로직은 외부에서 주입되어 리액트 코어로 전달된다는 것이다.

하지만 위에 useState의 코드를 가져오는줄 알았던 ReactCurrentDispatcher은 어디에서 로직을 주입받는 것일까? 리액트는 이런 로직을 바로 주입하지 않고 중간자를 두어 주입한다. 중간자를 관리하는 곳이 리액트의 shared패키지이다.

직접 주입하지 않고 중간자를 두는 이유 : 직접 주입을 해주는 것은 여러 의존성 문제가 발생할 수 있다. 그래서 중간자를 통해서 의존성을 줄이고 확장성과 유연성을 높이기 위해 사용한다.

리액트 코어 로직에서 hook을 가져오는 부분에 ReactSharedInternals를 봤었다. 바로 이 곳에서 외부의 코드를 주입하는 것이라고 볼 수 있다.

// packages/react/src/ReactSharedInternals.js
const ReactSharedInternals = {
  ReactCurrentDispatcher,
  ReactCurrentBatchConfig,
  ReactCurrentOwner,
};

실제로 해당 파일에 가서 코드를 보면 위에서 사용하는 ReactCurrentDispatcher를 주입 받기를 대기하고 있다고 보면된다. 주입하는 로직은 shared패키지에 가면 확인할 수 있다.

// packages/shared/ReactSharedInternals.js
import * as React from 'react';

const ReactSharedInternals =
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;

shared패키지에서도 동일한 이름으로 ReactSharedInternals파일이 있다. 이 부분에서 리액트 코어에 코드를 주입하는 것을 볼 수가 있다.

정리하자면 hook이 전달되는 과정은

  • reconciler -> shared/ReactSharedInternal -> react/ReactSharedInternal -> react/ReactCurrentDispatcher -> react/ReactHooks -> react -> 개발자

이러한 순서로 이뤄진다. 이제 reconciler에서 주입하는 방법에 대해서 알아보겠다.

Hook이 주입되는 과정

위에서 정리했듯이 hook은 reconciler에서 주입된다.

// packages/react-reconciler/src/ReactFiberHooks.new.js
import ReactSharedInternals from 'shared/ReactSharedInternals';

const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;

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;
...
  ReactCurrentDispatcher.current =
  current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
}

특히 ReactFiberHooks라는 곳에서 렌더링과 함께 hook을 주입해준다. renderWithHooks을 보면 ReactSharedInternals에서 가져온 ReactCurrentDispatcher.current에 hook을 주입해준다.

여기서 주의깊게 봐야하는 것은 current에 따라 주입하는 로직이 다르다는 것이다. 여기에서 current는 현재 Fiber를 의미하게 되고, current가 있다면 이미 마운트된 컴포넌트라는 것을 알수 있다. 그래서 ReactCurrentDispatcher.current에 주입하는 조건을 해석하면 컴포넌트가 마운트 된 여부에 따라 마운트 로직인지, 업데이트 로직인지 판단하는 것이다. 그래서 renderWithHooks가 최초 실행 시점에 workInProgress를 전부 초기화하는 것이다.

const HooksDispatcherOnMount: Dispatcher = {
  useRef: mountRef,
  useState: mountState,
...
};

const HooksDispatcherOnUpdate: Dispatcher = {
  useRef: updateRef,
  useState: updateState,
...
};

이렇게 마운트, 업데이트 로직이 나눠져서 각 상황에 따라 주입되는 것이다. 주입되는 순서를 다시 정리해보자면

컴포넌트 마운트 → Fiber 및 Hook 초기화 → 중간자 설정 → 마운트용 로직 실행 → 사용자에게 UI 표시

이렇게 된다. 이제 훅 객체가 어떻게 생성되는지 알아보겠다.

Hook 구현체 알아보기

Hook 객체 생성

컴포넌트가 처음 마운트되는 상황부터 알아보겠다.

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();

마운트될때 useState를 호출했기 때문에 mountState를 사용하게된다. 가장 먼저 hook 객체를 생성한다.

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

    baseState: null,
    baseUpdate: null,

    next: null,
  }

  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    workInProgressHook = workInProgressHook.next = hook
  }
  return workInProgressHook
}

hook 객체는 이렇게 생겼다. 우선 초기화된 hook 객체를 생성한다. 그리고 workInProgressHook여부에 따라 처음 사용되는 hook인지 판단한다. 그래서 처음 호출된 hook이라면 hook 호출 리스트를 시작하게 되고, 두번째부터는 next 프로퍼티에 저장되어서 리스트를 이어가게 된다.

전에 직접 만든 useState와 잠깐 비교해보겠다.

 const options: IOptions = {
   states: [],
   stateHook: 0,
 };

 const useState = <T>(initialState?: T) => {
   const { stateHook: index, states } = options;
   const state = (states[index] ?? initialState) as T;
   const setState = (newState: T) => {
     if (shallowEqual(state, newState)) return;
     states[index] = newState;
     _render();
   };
   options.stateHook += 1;
   return [state, setState] as const;
 };

그때는 hook의 순서를 유지해주기 위해 index라는 값을 생성해서 관리해줬었다. 그래서 useState를 호출할때마다 index를 올려주고, index를 포함한 setState를 컴포넌트에서 사용하면서 state의 상태를 개별적으로 관리가 가능했던 것이다. 이것이 리액트에서는 next라는 값으로 사용되는 것이다. 형식은 다르지만 원리는 같다. 개별 index를 부여하는 것이 아니라 hook자체를 객체화 시켜서 리스트 형식으로 만들어주는 것이다.

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
...

이제 useState의 기본값인 initialState를 hook 객체에 저장하면서 사용되는 것이다.

Hook 업데이트 로직 생성

 const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));

위에서 생성한 hook 객체로 state를 관리하고 순서를 관리한다. 이제 우리가 사용하는 setState를 생성해줘야 한다. setState를 만들어주기 위해서 queue라는 객체를 추가로 생성해준다.

  • pending: 현재 대기 중인 상태 업데이트 (업데이트 요청을 저장하는 곳)
  • lanes: 업데이트 우선순위를 관리하는 필드 (Concurrent Mode에서 중요)
  • dispatch: setState 역할을 하는 함수 (훅 내부에서 dispatchSetState로 연결됨)
  • lastRenderedReducer: useState에서는 basicStateReducer를 사용 (useReducer와 호환되도록 설계됨)
  • lastRenderedState: 마지막으로 렌더링된 상태 (최적화에 사용)

각 프로퍼티에 해당하는 내용이다. 즉, queue는 상태 업데이트를 저장하고 처리한다. 쉽게 예시를 하나 들어보겠다.

const [a, setA] = useState(0) // aHook
 
setA(_a => _a + 1) // firstUpdate
setA(_a => _a + 1) // secondUpdate
setA(_a => _a + 1) // thirdUpdate

컴포넌트에서 setState를 한번에 3번 요청하면 각 요청마다 상태를 변화시키고 렌더링하지 않는다. 세개의 요청을 하나로 합쳐서 최종적으로 적용될 state를 한번에 렌더링한다. 여기에서 사용되는 것이 queue이다. 모든 요청은 자세한 내용은 업데이트 구현체를 알아볼때 자세히 알아보겠다. 이렇게 생성된 hook 구현체와 업데이트 구현체는

return [hook.memoizedState, dispatch];

이렇게 hook 객체에 저장된 상태와 dispatch 배열로 리턴된다. 이렇게 반환된 배열을 우리가 컴포넌트에서 사용하는 것이다.

dispatch 살펴보기

dispatch에 대해서 좀더 알아보자.

const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));

dispatchSetState에 직접 바인딩을 해서 dispatch로직을 만들어 준다. 그 이유는 dispatch로직은 외부에 노출되어서 동작하기 때문에 직접 바인딩을 해줘야 문제가 생기지 않는다.

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  const lane = requestUpdateLane(fiber);

  const update: Update<S, A> = {
    lane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

가장 먼저 update 객체를 생성해준다. 여기에서 생성되는 update 객체는 업데이트하는 action과 동작 순서를 나타내는 next와 같은 값들이 들어가 있다. bind할때 받은 action, 즉 setState의 인자를 update 객체에 넣어주어 상태를 업데이트 하는 것이다.

  if (isRenderPhaseUpdate(fiber)) { // 렌더링중 발생한 업데이트
    enqueueRenderPhaseUpdate(queue, update);
  } else {

이제 각 상황에 따라 위에서 생성한 update객체와 queue를 다뤄주면된다. 우선 렌더링중 업데이트가 일어난 상황을 먼저 보겠다.

function enqueueRenderPhaseUpdate<S, A>(
  queue: UpdateQueue<S, A>,
  update: Update<S, A>,
) {
  didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
  const pending = queue.pending;
  if (pending === null) {
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;
}

이 함수를 통해서 hook 객체의 프로퍼이인 queue 객체의 pending 속성에 원형 Linked List 형태로 연결된다. 여기에서 설정하는 didScheduleRenderPhaseUpdateDuringThisPassrenderWithHooks에서도 사용된다.

  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    // Keep rendering in a loop for as long as render phase updates continue to
    // be scheduled. Use a counter to prevent infinite loops.
    let numberOfReRenders: number = 0;
    do {
      didScheduleRenderPhaseUpdateDuringThisPass = false;
      localIdCounter = 0;

      if (numberOfReRenders >= RE_RENDER_LIMIT) {
        throw new Error(
          'Too many re-renders. React limits the number of renders to prevent ' +
            'an infinite loop.',
        );
      }

      numberOfReRenders += 1;

      // Start over from the beginning of the list
      currentHook = null;
      workInProgressHook = null;

      workInProgress.updateQueue = null;

      ReactCurrentDispatcher.current = HooksDispatcherOnRerender;

      children = Component(props, secondArg);
    } while (didScheduleRenderPhaseUpdateDuringThisPass);
  }

렌더링하는 시점에 업데이트 동작을 저장하고, 다시 리렌더링을 하면서 렌더링 시점에 등록된 업데이트를 실행하는 것이다. 여기에서 무한 리렌더링이 발생하지 않도록 횟수제한을 둔것도 볼수가 있다.

다시 돌아와서 이제 렌더 페이즈가 아닌 일반 유휴 상태에서 업데이트가 일어나면 어떻게 되는지 보겠다.

    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher;
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        }
      }
    }

    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      const eventTime = requestEventTime();
      scheduleUpdateOnFiber(root, fiber, lane, eventTime);
      entangleTransitionUpdate(root, queue, lane);
    }
  }

렌더 페이즈일때 업데이트하는 것보다 훨씬 복잡해보인다. 자세히 알아보기보다는 대략적으로 알아보겠다. 우선 eagerState, 현재 state를 구한다. 그리고 업데이트하는 state와 eagerState가 동일한지 확인하고, 동일하면 업데이트하지 않는다. 만약 다른 state로 업데이트한다면 enqueueConcurrentHookUpdate를 통해 업데이트를 queue에 추가하고, 리렌더링될 차례가 되면 scheduleUpdateOnFiber 호출하여 fiber에 렌더링을 요청한다.

마무리

나름 열심히 분석해보려고 했는데 생각보다 어렵다... 아무래도 방대한 양의 소스 코드가 얽혀있기도 하고, 내가 참고했던 블로그는 리액트 16버전을 설명한 내용이었다. 하지만 나는 리액트 18버전을 분석하면서 변동된 것들이 많았다. 특히 Fiber의 기능이 극대화되면서 lane과 같은 우선순위 프로퍼티들이 추가되는 등 여러가지 변화들이 있다.

한술에 배부를 수는 없다. 계속 소스코드를 분석해보는 것도 많은 도움이 되지 않을까 싶다. 아직 hook 객체와 queue, VDOM사이에 어떤 상호작용을 하는지 제대로 파악이 안됐기 때문에 다음에는 업데이트 로직을 조금더 알아보면서 이해해보자.

개의 댓글