본문 바로가기
오픈소스/react

[React Hooks] useState 분석해보기 (진행 중)

by 위그든씨 2024. 10. 9.

useState는 React 깃허브에서 @packages/react/src/ReactHooks.js에서 export 중이다.

// @packages/react/src/ReactHooks.js

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

제네릭 타입 S를 담아주면 리턴하는 값은 [S타입, Dispatch<BasicStateAction<S>>] (S타입에 대한 Dispatch하는 함수)

useState의 리턴 값을 알아가기 위해 resolveDispatcher() 라는 함수를 추적

function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  if (__DEV__) {
    if (dispatcher === null) {
      console.error(
        'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
          ' one of the following reasons:\n' +
          '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
          '2. You might be breaking the Rules of Hooks\n' +
          '3. You might have more than one copy of React in the same app\n' +
          'See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.',
      );
    }
  }
  // Will result in a null access error if accessed outside render phase. We
  // intentionally don't throw our own error because this is in a hot path.
  // Also helps ensure this is inlined.
  return ((dispatcher: any): Dispatcher);
}

우리가 사용하는 useState는 resolveDispatcher 함수가 반환하는 useState를 가져오는 것이고 

resolveDispatcher 함수는 ((dispatcher: any): Dispatcher) 를 반환 중.

((dispatcher: any): Dispatcher)가 뜻하는 것이 이해가 가지 않아서 클로드에 질문. 아래는 그 답변

 

  • dispatcher를 any 타입으로 캐스팅한 후, 다시 Dispatcher 타입으로 캐스팅합니다.
  • 이는 TypeScript나 Flow와 같은 정적 타입 시스템에서 타입 체크를 위한 것입니다.
  • Dispatcher 타입은 아마도 React 훅의 실제 구현을 포함하는 객체의 타입일 것입니다.

 

import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';

Dispatcher 타입은 위 모듈에서 가져오고 해당 타입의 코드는 아래와 같고 우리가 흔히 사용 중인 훅들에 대한 타입들이 정의되어져 있다.

export type Dispatcher = {
  use: <T>(Usable<T>) => T,
  readContext<T>(context: ReactContext<T>): T,
  useState<S>(initialState: (() => S) | S): [S, Dispatch<BasicStateAction<S>>],
  useReducer<S, I, A>(
    reducer: (S, A) => S,
    initialArg: I,
    init?: (I) => S,
  ): [S, Dispatch<A>],
  unstable_useContextWithBailout?: <T>(
    context: ReactContext<T>,
    select: (T => Array<mixed>) | null,
  ) => T,
  useContext<T>(context: ReactContext<T>): T,
  useRef<T>(initialValue: T): {current: T},
  useEffect(
    create: () => (() => void) | void,
    deps: Array<mixed> | void | null,
  ): void,
  useEffectEvent?: <Args, F: (...Array<Args>) => mixed>(callback: F) => F,
  useInsertionEffect(
    create: () => (() => void) | void,
    deps: Array<mixed> | void | null,
  ): void,
  useLayoutEffect(
    create: () => (() => void) | void,
    deps: Array<mixed> | void | null,
  ): void,
  useCallback<T>(callback: T, deps: Array<mixed> | void | null): T,
  useMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T,
  useImperativeHandle<T>(
    ref: {current: T | null} | ((inst: T | null) => mixed) | null | void,
    create: () => T,
    deps: Array<mixed> | void | null,
  ): void,
  useDebugValue<T>(value: T, formatterFn: ?(value: T) => mixed): void,
  useDeferredValue<T>(value: T, initialValue?: T): T,
  useTransition(): [
    boolean,
    (callback: () => void, options?: StartTransitionOptions) => void,
  ],
  useSyncExternalStore<T>(
    subscribe: (() => void) => () => void,
    getSnapshot: () => T,
    getServerSnapshot?: () => T,
  ): T,
  useId(): string,
  useCacheRefresh?: () => <T>(?() => T, ?T) => void,
  useMemoCache?: (size: number) => Array<any>,
  useHostTransitionStatus?: () => TransitionStatus,
  useOptimistic?: <S, A>(
    passthrough: S,
    reducer: ?(S, A) => S,
  ) => [S, (A) => void],
  useFormState?: <S, P>(
    action: (Awaited<S>, P) => S,
    initialState: Awaited<S>,
    permalink?: string,
  ) => [Awaited<S>, (P) => void, boolean],
  useActionState?: <S, P>(
    action: (Awaited<S>, P) => S,
    initialState: Awaited<S>,
    permalink?: string,
  ) => [Awaited<S>, (P) => void, boolean],
};

다시 resolveDispatcher 함수로 돌아가보면 dispatcher는 ReactSharedInternals의 H 이다.

ReactSharedInternals 을 추적해보면 'shared/ReactSharedInternals' 모듈에서 가져오는 중

이 모듈에 들어가보면 아래와 같이 적혀있어서 일반적인 사용자나 개발자가 직접 사용해서는 안 되는 부분이라고 명시한 것을 볼 수 있다.

import * as React from 'react';

const ReactSharedInternals =
  React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;

export default ReactSharedInternals;

이 부분부터 이해가 가지 않아서 구글링을 해보니 다른 분의 포스팅을 통해 의미를 알 수 있었음.

@packages/react-reconciler/src에서 hooks 과 관련된 모듈을 살펴보니 ReactFiberHooks 파일을 찾을 수 있었고 해당 모듈에서 useState를 검색해보니

렌더 시점에 따라 useState를 각각 export중이었다

const ContextOnlyDispatcher

const HooksDispatcherOnMount

const HooksDispatcherOnRerender

HooksDispatcherOnMountInDEV

HooksDispatcherOnMountWithHookTypesInDEV

HooksDispatcherOnUpdateInDEV

HooksDispatcherOnRerenderInDEV

InvalidNestedHooksDispatcherOnMountInDEV

InvalidNestedHooksDispatcherOnUpdateInDEV

InvalidNestedHooksDispatcherOnRerenderInDEV

 

상황에 맞게 훅의 동작을 최적화하고 올바른 사용법을 위해 각각의 useState를 담고 있는 것으로 추정.

onMount in Dev에서의 useState 훅의 정의는 아래와 같다

 useState<S>(
      initialState: (() => S) | S,
    ): [S, Dispatch<BasicStateAction<S>>] {
      currentHookNameInDev = 'useState';
      mountHookTypesDev();
      const prevDispatcher = ReactSharedInternals.H;
      ReactSharedInternals.H = InvalidNestedHooksDispatcherOnMountInDEV;
      try {
        return mountState(initialState);
      } finally {
        ReactSharedInternals.H = prevDispatcher;
      }
    },
// 현재 디스패처를 저장하고, 새로운 디스패처로 교체
// 이는 훅이 올바른 컨텍스트에서 호출되는지 확인하기 위함.

const prevDispatcher = ReactSharedInternals.H;
ReactSharedInternals.H = InvalidNestedHooksDispatcherOnMountInDEV;

위의 코드는 useState의 외부 인터페이스를 정의하고 실제 상태 관리 로직은 mountState에서 하는 것을 알 수 있었다.

* 마운트 시점에서는 mountState, 업데이트 시점은 updateState, 렌더 시점은 rendereState임

위의 코드를 추적해보면 아래 코드들을 볼 수 있다

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

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

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

mountState가 반환하는 dispatch가 hook.memoizedState를 건드는 로직은 아래와 같다.

디스패치 함수 생성:

mountState는 dispatchSetState를 현재 파이버(currentlyRenderingFiber)와 큐(queue)에 바인딩하여 dispatch 함수를 생성합니다.
이 dispatch 함수는 컴포넌트에 반환되어 사용자가 호출할 수 있게 됩니다.


상태 업데이트 트리거:

사용자가 dispatch 함수를 호출하면, 이는 dispatchSetState를 호출합니다.
dispatchSetState는 내부적으로 dispatchSetStateInternal을 호출합니다.


업데이트 스케줄링:

dispatchSetStateInternal은 새로운 업데이트 객체를 생성하고 이를 파이버의 업데이트 큐에 추가합니다.
이 과정에서 React는 컴포넌트의 재렌더링을 스케줄링합니다.


렌더 단계:

React는 스케줄링된 업데이트에 따라 컴포넌트의 재렌더링을 시작합니다.
이 과정에서 파이버의 업데이트 큐를 처리합니다.


상태 계산:

렌더링 과정에서 React는 이전 상태와 업데이트 액션을 기반으로 새로운 상태를 계산합니다.
계산된 새로운 상태 값으로 hook.memoizedState가 업데이트됩니다.


새로운 상태로 재렌더링:

컴포넌트는 업데이트된 상태 값으로 재렌더링됩니다.
이 과정에서 새로운 상태가 UI에 반영됩니다.

useState가 반환하는 첫 번째 값인 state의 추적하기 위해 hook.memoizedState를 살펴본다

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();
    if (shouldDoubleInvokeUserFnsInHooksDEV) {
      setIsStrictModeForDevtools(true);
      // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
      initialStateInitializer();
      setIsStrictModeForDevtools(false);
    }
  }
  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 코드를 살펴본다.

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

위 함수는 전역 변수인 workInProgressHook를 반환하는데 이것의 초기 값은 아래와 같다.

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

let workInProgressHook: Hook | null = null;

mountWorkInProgressHook 함수의 목적은 새로운 훅을 생성하고 현재 렌더링 중인 컴포넌트의 훅 리스트에 추가하는 역할을 하는 것.

hook의 각각의 속성의 정의는 

 

  • memoizedState: 훅의 현재 상태를 저장합니다.
  • baseState: 업데이트 큐를 처리하기 전의 초기 상태입니다.
  • baseQueue: 처리되지 않은 업데이트들의 큐입니다.
  • queue: 향후 처리될 업데이트들의 큐입니다.
  • next: 다음 훅을 가리키는 포인터입니다.

다음 훅을 가리키는 포인터가 있다는 것은 리스트 형태인건가 싶다.

workInProgreeHook이 null이라는 것은 첫 번째 훅이라는 것을 의미하는 것이라 첫 값으로 hook일 입혀주는 것이고, 첫 번쨰가 아니라면 다음 포인터로 이 hook을 연결해주는 것. 

mountWorkInProgressHook가 반환하는 것이 현재 컴포넌트의 훅들이라는 것을 알았으니 다시 mountStateImpl로 돌아가본다.

if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
    initialState = initialStateInitializer();
    if (shouldDoubleInvokeUserFnsInHooksDEV) {
      setIsStrictModeForDevtools(true);
      // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
      initialStateInitializer();
      setIsStrictModeForDevtools(false);
    }
  }

useState 초깃값에 넣어준 게 함수라면 실행한 뒤 리턴 값을 initialState에 재할당. 개발모드면 함수를 한 번 더 실행 해준다는 뜻

  hook.memoizedState = hook.baseState = initialState;

 

위의 초깃값 처리 후 hook의 현재 상태에 저장.

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

이 hook.queue는 mountState가 반환하는 두번째 값인 dispatcher에 영향을 끼치는 값으로 hook.queue는 향후 처리 될 업데이트들의 큐를 뜻함

이제 mountState로 돌아가보면  해당 함수가 리턴하는 [hook.memoizedState, dispatch] 에서 첫 번째 값에 대해서는 파악할 수 있었음. 이제 dispatch 부분을 살펴보면 dispatchSetState.bind 부분이 있다. 

const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  
  
  
function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  if (__DEV__) {
    if (typeof arguments[3] === 'function') {
      console.error(
        "State updates from the useState() and useReducer() Hooks don't support the " +
          'second callback argument. To execute a side effect after ' +
          'rendering, declare it in the component body with useEffect().',
      );
    }
  }

  const lane = requestUpdateLane(fiber);
  const didScheduleUpdate = dispatchSetStateInternal(
    fiber,
    queue,
    action,
    lane,
  );
  if (didScheduleUpdate) {
    startUpdateTimerByLane(lane);
  }
  markUpdateInDevTools(fiber, lane, action);
}

dispatchSetState의 실제 동작은 dispatchSetStateInternal에 의해 이뤄짐.

function dispatchSetStateInternal<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
  lane: Lane,
): boolean {
  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // The queue is currently empty, which means we can eagerly compute the
      // next state before entering the render phase. If the new state is the
      // same as the current state, we may be able to bail out entirely.
      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);
          // Stash the eagerly computed state, and the reducer used to compute
          // it, on the update object. If the reducer hasn't changed by the
          // time we enter the render phase, then the eager state can be used
          // without calling the reducer again.
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            // Fast path. We can bail out without scheduling React to re-render.
            // It's still possible that we'll need to rebase this update later,
            // if the component re-renders for a different reason and by that
            // time the reducer has changed.
            // TODO: Do we still need to entangle transitions in this case?
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return false;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          if (__DEV__) {
            ReactSharedInternals.H = prevDispatcher;
          }
        }
      }
    }

    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
      return true;
    }
  }
  return false;
}

 

*** 여기서부터는 아직 이해가 안돼서 직접 useState를 구현해보면서 이해해보고 재작성 해보겠음

 

참고 사이트:https://goidle.github.io/react/in-depth-react-hooks_1/