useState는 어떻게 구현되어 있을까?

aken·2025년 4월 18일
1

useState를 정말 많이 사용하는데, React 내부에서 어떻게 구현됐는지 알고 계신가요?
저는 몰랐습니다... 그래서 이번 기회에 한 번 React 코드를 뜯어보면서 useState를 파헤쳐보려 합니다!

React repository 보러가기 ↗️

react 패키지

아래 내용은 react 폴더에 있는 README에 적혀있는 내용입니다.

The react package contains only the functionality necessary to define React components. It is typically used together with a React renderer like react-dom for the web, or react-native for the native environments.

즉, react 폴더 안에는 react 컴포넌트를 정의하는데 필요한 기능들만 들어있습니다. react-dom이나 react-native와 같은 react renderer와 함께 사용합니다.

여기서 말하는 react renderer는 무엇일까요?

react renderer는 컴포넌트를 실제 UI로 바꿔줍니다. react 패키지 자체는 컴포넌트를 정의하고, 상태를 관리하고, JSX를 JS 객체로 만들 뿐 화면에 실제로 그려주지 않습니다. 실제 브라우저 화면에 컴포넌트를 그리는 역할을 renderer가 합니다.

useState

useState는 packages/react/src/ReactHooks.js에 있습니다. 저희는 이 useState를 가져와서 사용하고 있는데요.

resolveDispatcher 함수가 객체를 반환하는데, 그 객체는 useState를 메서드로 가지고 있나 봅니다.
그 메서드가 반환하는 값이 useState 훅의 반환값이 됩니다.

resolveDispatcher

resolveDispatcher는 useState와 동일한 파일에 있습니다.

이 함수는 ReactSharedInternals.H를 반환합니다.

그럼 ReactSharedInternals.H에는 어떤 속성이 들어있을까요?

ReactSharedInternals

useState를 다시 보면, ReactSharedInternals.H의 useState 메서드를 호출하고 있습니다.

shared
/ReactSharedInternals.js
를 보면, ReactSharedInternals는 React의 특정 속성을 참조하고 있습니다.

그럼 ReactSharedInternals.H의 useState 메서드는 어디서, 어떻게 정의된 걸까요?

react-reconciler 패키지

packages/react-reconciler/src
/ReactFiberHooks.js
파일에서 조건에 따라 ReactSharedInternals.H에 값을 할당하고 있습니다.

ReactFiberHooks 파일을 파헤치기 전에 react-reconciler 패키지에 대해 알아봅시다.

react-reconciler에서 컴포넌트를 호출하고 reconciliation를 진행합니다. 여기서, reconciliation이란 기존의 virtual dom과 변경된 virtual dom을 비교하여 변경된 부분을 파악하고 이를 실제 DOM에 반영하는 과정입니다.

이 패키지의 대부분 하위 파일들 네이밍에 fiber 키워드가 포함되어 있는데요.

fiber는 무엇일까요?

fiber

vitual dom을 이루는 노드 객체입니다.
이 객체로 상태, 훅, 라이프 사이클 등을 관리합니다.

fiber를 생성하는 코드도 물론 react-reconciler에 있습니다.

ReactFiberHooks.js

파일 보러가기 ↗️

이 파일에서 ReactSharedInternals.H에 dispatcher 객체를 할당하는 코드가 여기저기 존재합니다.

renderWithHooks & renderWithHooksAgain

renderWithHooks와 renderWithHooksAgain은 상황에 따라 현재 활성화된 dispatcher를 ReactSharedInternals.H에 할당합니다.

아래 사진은 renderWithHooks 함수에서 ReactSharedInternals.H에 할당하는 코드입니다.

__DEV__로 감싸진 코드는 개발 환경에서만 실행되는 코드로, 앞으로도 마주친다면 넘기도록 하겠습니다!

ReactSharedInternals.H은 조건에 따라 HooksDispatcherOnMount 또는 HooksDispatcherOnUpdate를 참조합니다.

renderWithHooksAgain 함수에서는 ReactSharedInternals.H가 HooksDispatcherOnRerender를 참조합니다.

HooksDispatcherOnMount, HooksDispatcherOnUpdate, HooksDispatcherOnRerender 모두 공통적으로 useState가 메서드로 존재합니다. 차이점은 useState로 할당된 함수가 다른데요.

mountState, updateState 코드를 보면서 어떻게 다른지 한 번 보시죠!

mountState

컴포넌트가 최초로 mount될 때 호출

mountState에서 mountStateImpl 함수를 호출하고, mountStateImpl에서 mountWorkInProgressHook를 호출하고 있습니다.

즉, mountState > mountStateImpl > mountWorkInProgressHook 순서대로 호출됩니다.


function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook(); // hook 객체 생성
  // ...
  
  return hook;
}

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

mountWorkInProgressHook

컴포넌트 안에서 사용하는 훅들은 링크드 리스트 형태로 관리됩니다.

mountWorkInProgressHook에서 아래 과정을 거칩니다.

  1. 새로운 hook 객체를 생성
  2. 훅의 링크드 리스트에 연결
  3. 생성된 hook 객체를 반환
// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.
let currentHook: Hook | null = null;
let workInProgressHook: Hook | null = null;

function mountWorkInProgressHook(): Hook {
  // 1. hook 객체 생성
  const hook: Hook = {
    memoizedState: null, // 최신 상태 값

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

    next: null, // 다음 훅을 가리키는 포인터
  };

  // 2. 훅의 링크드 리스트에 hook 객체 추가
  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;
  }
  
  // 3. 생성된 hook 객체 반환
  return workInProgressHook;
}

mountStateImpl

mountWorkInProgressHook을 통해 새로운 hook 객체 생성하여, 초기값을 hook 객체에 저장합니다.

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  // 1. 새로운 hook 객체 생성
  const hook = mountWorkInProgressHook();
  
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    initialState = initialStateInitializer();
    // ...
  }
  
  // 2. initialState를 hook 객체에 저장
  hook.memoizedState = hook.baseState = initialState;
  
  // 3. 상태 업데이트를 관리할 queue를 생성하여 hook 객체에 저장
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null, // 대기 중인 상태 업데이트들의 연결 리스트를 저장
    lanes: NoLanes, // 업데이트 우선 순위
    dispatch: null, // 디스패치 함수
    lastRenderedReducer: basicStateReducer, // 마지막으로 렌더링된 리듀서
    lastRenderedState: (initialState: any), // 마지막으로 렌더링된 상태
  };
  hook.queue = queue;
  
  return hook;
}

mountState

지금까지의 과정을 정리하면 다음과 같습니다.

  • mountWorkInProgressHook 함수는 새로운 훅 객체를 생성하고, 이 객체를 훅의 링크드 리스트에 연결합니다.
  • mountStateImpl 함수는 생성된 훅 객체의 memoizedState에 초기값을 저장합니다.
  • 이때, 생성된 훅 객체의 memoizedState가 곧 useState의 반환값에서 배열의 첫 번째 요소가 됩니다.

즉, const [state, setState] = useState(0)에서 state가 hook.memoizedState를 참조하는 것입니다.

그럼, setState는 어떻게 만들어지는 걸까요?

mountState에서 dispatchSetState를 바인딩하여 dispatch 함수를 만드는데요. 이 dispatch 함수가 setState가 됩니다.

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];
}

dispatchSetState

requestUpdateLane를 호출하여 반환하는 값은 업데이트 작업의 우선 순위를 의미하는 lane을 반환하여, dispatchSetStateInternal의 인수로 넘겨줍니다.

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  if (__DEV__) {
    // ...
  }
  
  // 업데이트 작업의 우선 순위
  const lane = requestUpdateLane(fiber);
  
  const didScheduleUpdate = dispatchSetStateInternal(
    fiber,
    queue,
    action,
    lane,
  );
  if (didScheduleUpdate) {
    startUpdateTimerByLane(lane);
  }
  markUpdateInDevTools(fiber, lane, action);
}

dispatchSetStateInternal

이 함수에서 상태 업데이트가 처리됩니다.
내부 로직을 간단히 정리하면 다음과 같습니다.

  • 렌더 단계에서 업데이트가 발생할 경우, 나중에 다시 처리하기 위해 특정 큐에 업데이트 넣기
  • 동일한 상태로 업데이트 할 경우, 큐에 작업을 넣으나 렌더링하지 않도록 처리
  • React는 상태 업데이트가 일어나도 바로 렌더링하지 않고, 큐에 업데이트 작업을 넣기
function dispatchSetStateInternal<S, A>(
  fiber: Fiber, // 업데이트가 발생한 fiber
  queue: UpdateQueue<S, A>,
  action: A, // 새로운 상태 값 또는 reducer에 전달될 액션 (예시: setState(value)에서 value)
  lane: Lane, // 업데이트의 우선 순위
): boolean {
  // 새로운 업데이트 객체 => 큐에 들어가서 상태 변경을 일으킴
  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    gesture: null,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    // 렌더 단계에서 업데이트될 경우
    // 특정 큐에 넣어서 나중에 다시 처리
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    // 현재 Fiber와 새로 계산 중인 Fiber를 연결하는 포인터
    const alternate = fiber.alternate;
    
    if (
      fiber.lanes === NoLanes && // 다른 업데이트 작업이 없다면
      (alternate === null || alternate.lanes === NoLanes) // alternate에도 다른 업데이트 작업이 없을 경우 
    ) {
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher = null;
        if (__DEV__) {
          prevDispatcher = ReactSharedInternals.H;
          ReactSharedInternals.H = InvalidNestedHooksDispatcherOnUpdateInDEV;
        }
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            // 이전 상태와 새 상태가 동일할 경우 큐에 추가하나 렌더링 일어나지 않게 false 반환
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return false; // 불필요한 리렌더링을 피하기 위해 false 반환
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          if (__DEV__) {
            ReactSharedInternals.H = prevDispatcher;
          }
        }
      }
    }

    // 업데이트를 큐에 넣기 (여기서 root는 root fiber를 의미)
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      // 업데이트를 스케줄링 (바로 렌더링이 일어나지 x. 렌더링은 react scheduler에 의존) 
      scheduleUpdateOnFiber(root, fiber, lane);
      // transition에 관한 업데이트를 묶기 
      entangleTransitionUpdate(root, queue, lane);
      
      // 업데이트가 스케줄링 됐음을 알리기 위해 true 반환
      return true;
    }
  }
    
  // 업데이트가 스케줄링되지 않았으면 false 반환
  return false;
}

updateState

컴포넌트가 리렌더링될 때 호출

updateState는 코드가 간단합니다.

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState);
}

updateReducer를 보면 뭔가 위에서 본 것 같지 않나요?

updateWorkInProgressHook에서 이전에 생성한 queue 객체 반환합니다.

function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  // 이전에 생성한 queue 객체 반환
  const hook = updateWorkInProgressHook();
  return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
}

updateReducerImpl 함수는 내부 코드가 복잡하고, 아직까지 완전히 이해하진 못했습니다 🥲

이해한 범위 내에서 간단히 설명하면,

  • 기존의 업데이트 작업과 대기 중인 업데이트 작업을 병합하여 큐에 저장합니다.
  • 큐를 순회하면서 업데이트를 확인합니다.
    • 해당 업데이트가 현재 렌더링에서 처리해야 하는지, 나중에 처리해도 되는지 판단합니다.
    • 처리해야 한다면, reducer로 새로운 상태 값을 계산합니다.
  • 순회를 마치고 나서, 최종적으로 업데이트된 상태와 dispatch가 담긴 배열을 반환합니다.

참고

How does useState() work internally in React?
useState의 내부 동작 원리

0개의 댓글