useState는 어떻게 동작할까?

드뮴·4일 전
13

🐾 리액트

목록 보기
3/3
post-thumbnail

리액트 렌더링을 공부하고 구현해서 간단한 투두를 만들어보는게 목표였다. 그런데 useState를 구현해두지 않아서 만들 수 있는게 제한적이었다.
useState 내부 동작도 확인하고 구현하기 위해 학습해보았다.

목차

useState란?
ㅤ사용법
ㅤ상태 업데이트 함수로 업데이트를 하지 않는다면?

useState 실제 코드 확인하기
ㅤReactHooks
ㅤReactFiberHooks
ㅤmountState
ㅤupdateState
ㅤrerenderState
ㅤ정리하기

useState 흐름과 동작 원리 정리하기
ㅤuseState의 전체 동작 흐름
ㅤ리액트의 훅
ㅤ클로저로 구현된 useState


useState란?

useState는 리액트에서 제공하는 훅으로, 상태 관리를 가능하게 한다.
useState와 같은 훅은 조건문, 반복문, 중첩된 함수에서 선언할 수 없다. 이 이유는 뒤에서 자세히 다룰 예정이다.

사용법

  • 위 코드를 보면 count, setCount 2개의 값이 있다. count는 현재 상태 값이고, setCount는 상태를 변경해주는 함수다.
  • useState는 [현재 상태 값, 상태 변경 함수]를 반환한다.
  • 위 코드에서 증가 버튼을 누르면 setCount 함수는 count 값을 1 증가시킨다. 그러면 리액트는 setCount가 실행되면 상태 변화를 인지하고 렌더링을 시켜 숫자가 변경되는 걸 화면에 표시해준다.

상태 업데이트 함수로 업데이트를 하지 않는다면?

useState를 사용하면 상태와 상태 업데이트 함수를 반환해준다. 그렇다면 상태 업데이트는 상태 업데이트 함수를 통해 수행한다. 그런데 useState가 반환하는 상태 변경 함수를 사용하지 않고 그냥 상태를 조작한다면 어떻게 될까?

리액트에서 상태를 직접 수정하면 리액트는 이를 감지하지 못한다.

감지하지 못하는 이유는 다음과 같다.

  1. 리액트는 상태 변수에 대한 getter/setter 같은 변경 감지 매커니즘을 설정하지 않는다. 즉, 상태를 우리가 직접 조작해도 리액트는 이를 알아차리지 못한다. useState에서 반환하는 상태 자체는 일반 자바스크립트 변수일 뿐이다.
  2. 직접 변수를 수정해주면 리액트 렌더링 시스템에 알려야하는데 알리는 트리거가 없다.
  3. 상태 변수 자체는 클로저의 참조가 아니라 값의 복사본이다. 즉, 변수를 직접 수정해도 리액트가 관리하는 내부 상태에는 영향을 미치지 않는다.

상태 업데이트 함수를 사용하면?

그렇다면 상태 업데이트 함수를 사용해 상태를 업데이트하면 무엇이 다를까?

  1. setState는 함수는 컴포넌트 리렌더링을 예약한다. 즉, 이 함수를 통해 리렌더링을 트리거한다.
  2. 상태 업데이트는 큐에 추가되고 배치 처리된다.
  3. setState 내부에서는 상태를 계산하며 이전과 달라진게 없다면 리렌더링을 하지 않기 때문에 불필요한 리렌더링도 방지해준다.

useState 실제 코드 확인하기

코드는 리액트 19.0.0 버전을 참고하였습니다.

ReactHooks

리액트의 훅이 모여있는 파일이다. useState, useEffect, useCallback, useRef 등 자주 본 훅이 있을 것이다.
여기서는 내부 구현을 볼 수는 없어서 내부 구현은 밑에 작성해두었다.

function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  return ((dispatcher: any): Dispatcher);
}

export function getCacheForType<T>(resourceType: () => T): T {
  const dispatcher = ReactSharedInternals.A;
  if (!dispatcher) {
    // If there is no dispatcher, then we treat this as not being cached.
    return resourceType();
  }
  return dispatcher.getCacheForType(resourceType);
}

export function useContext<T>(Context: ReactContext<T>): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useContext(Context);
}

export function unstable_useContextWithBailout<T>(
  context: ReactContext<T>,
  select: (T => Array<mixed>) | null,
): T {
  if (!(enableLazyContextPropagation && enableContextProfiling)) {
    throw new Error('Not implemented.');
  }

  const dispatcher = resolveDispatcher();
  return dispatcher.unstable_useContextWithBailout(context, select);
}

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

export function useReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useReducer(reducer, initialArg, init);
}

export function useRef<T>(initialValue: T): {current: T} {
  const dispatcher = resolveDispatcher();
  return dispatcher.useRef(initialValue);
}

export function useEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, deps);
}

export function useInsertionEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  const dispatcher = resolveDispatcher();
  return dispatcher.useInsertionEffect(create, deps);
}

export function useLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  const dispatcher = resolveDispatcher();
  return dispatcher.useLayoutEffect(create, deps);
}

export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useCallback(callback, deps);
}

export function useMemo<T>(
  create: () => T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useMemo(create, deps);
}

(... 코드가 길어 일부 훅은 지웠습니다.)

export function useActionState<S, P>(
  action: (Awaited<S>, P) => S,
  initialState: Awaited<S>,
  permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
  if (!enableAsyncActions) {
    throw new Error('Not implemented.');
  } else {
    const dispatcher = resolveDispatcher();
    // $FlowFixMe[not-a-function] This is unstable, thus optional
    return dispatcher.useActionState(action, initialState, permalink);
  }
}

ReactFiberHooks

이 파일에 useState 구현이 있었지만, 함수 호출하는 부분이 많아 실제 동작을 이해하기 어렵다.
먼저 ReactFiberHooks에 있는 useState 함수를 간단하게 이해해보고, 각각을 자세히 알아볼 생각이다.

useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  currentHookNameInDev = 'useState';
  warnInvalidHookAccess();
  updateHookTypesDev();
  const prevDispatcher = ReactSharedInternals.H;
  ReactSharedInternals.H = InvalidNestedHooksDispatcherOnUpdateInDEV;
  try {
    return rerenderState(initialState);
  } finally {
    ReactSharedInternals.H = prevDispatcher;
  }
}
  • currentHookNameInDev 설정: 디버깅 목적으로 현재 훅 이름을 추적하는 부분이다.
  • warnInvalidHookAccess(): 훅 사용 규칙 위반 경고
  • updateHookTypesDev(): 디버깅을 위한 훅 타입 추적
  • ReactSharedInternals.H: 리액트의 dispatcher 객체 참조
  • InvalidNestedHooksDispatcherOnUpdateInDEV: 중첩된 훅 호출 감지용 객체
  • 실제 작업은 rerenderState(initialState)에서 수행한다.

그렇다면 rerenderState 함수를 찾아서 동작 과정을 찾아볼 것이다. 또한 디스패처 시스템이 어떻게 동작하는지도 봐야한다.


mountState, updateState, rerenderState를 알아볼 것이다.
마찬가지로 이 함수들도 ReactFiberHooks 파일에 있다.

mountState

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook(); // 훅 객체 생성
  if (typeof initialState === 'function') { // initialState가 함수일 때 
    // useState(() => functionValue())와 같이 호출되는 경우
    const initialStateInitializer = initialState; 
    initialState = initialStateInitializer(); // 함수를 호출해 초기 상태 계산
  }
  // 상태 및 큐 초기화
  hook.memoizedState = hook.baseState = initialState; // 초기 상태 저장
  const queue: UpdateQueue<S, BasicStateAction<S>> = { // 업데이트 큐 객체 생성
    pending: null,  // 처리 대기 중인 업데이트
    lanes: NoLanes, // 업데이트 우선순위 정보
    dispatch: null, // 나중에 설정될 setState 함수
    lastRenderedReducer: basicStateReducer, // 상태 계산에 사용되는 리듀서
    lastRenderedState: (initialState: any), // 마지막으로 렌더링된 상태
  };
  hook.queue = queue; // 생성된 큐를 훅에 연결
  return hook;
}

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( // dispatchSetState 함수를 현재 렌더링 중인 Fiber와 상태 큐에 바인딩하여 디스패치 함수 생성
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch]; // 상태 값과 상태 업데이트 함수 반환
}

mountState는 첫 렌더링 시 호출되는 useState 구현이다.
컴포넌트가 처음 마운트될 때 상태를 초기화하는 내부 구현이다.

mountStateImpl 함수의 동작

mountWorkInProgressHook: 새로운 훅 객체를 생성하고, 현재 컴포넌트의 훅 리스트에 연결한다.

훅 객체란?

const hook = {
  memoizedState: null, // 마지막으로 렌더링된 상태
  baseState: null,     // 기본 상태 (업데이트 적용 전)
  baseQueue: null,     // 기본 업데이트 큐
  queue: null,         // 현재 업데이트 큐
  next: null           // 다음 훅 링크
}

리액트는 컴포넌트 인스턴스 별로 훅들을 연결 리스트 형태로 관리한다. 각 훅은 자신만의 상태와 큐를 가지고, next 속성을 통해 다음 훅과 연결된다. 이 방식을 통해 리액트는 훅의 호출 순서를 추적하고 각 렌더링 사이에 상태를 유지할 수 있다.

예를 들어 컴포넌트에서 useState를 2개 사용해주고 useEffect를 사용해준다면?
이 3개의 훅은 연결리스트로 나타내는 것이다. 이 순서를 유지해서 관리하기 때문에 순서가 바뀌게 되면 상태 업데이트가 제대로 되지 않는다. 그래서 이런 훅들을 조건문을 사용해 정의해주는 방식을 사용할 수 없는 것이다.

mountState 함수의 동작

mountStateImpl를 호출해서 훅을 초기화해준다. 디스패치 함수를 생성하고, [hook.memoizedState, dispatch]를 반환한다. 이는 각각 상태와 상태 변경 함수이다.

const [count, setCount] = useState(0)와 같이 호출하면 반환해주는 값인 것이다.

const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind( // dispatchSetState 함수를 현재 렌더링 중인 Fiber와 상태 큐에 바인딩하여 디스패치 함수 생성
  null,
  currentlyRenderingFiber, // 훅을 사용하는 Fiber 객체 정보
  queue, // 상태 업데이트 큐
): any);
  • 이 코드를 보면 클로저로 구현된 부분임을 볼 수 있다. 현재 훅을 사용하는 컴포넌트가 어떤 컴포넌트인지 해당 컴포넌트에 대한 Fiber 객체와 상태 업데이트 큐를 기억하게 구성되어있다.
  • dispatchSetState 함수에 바인딩해서 디스패치 함수를 생성한다. 밑에 해당 함수 내용을 더 자세히 적어두었다.

dispatchSetState

상태를 설정하는 함수인 setState가 호출될 때 실행되는 함수다. 리액트 상태 업데이트의 핵심 부분이다.

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

dispathchSetState는 상태 설정 함수(setState)의 상위 레벨 wrapper이다.

  • 업데이트 우선순위 계산: 적합한 우선순위 결정
  • 내부 디스패치 함수 호출: 실제 업데이트 로직은 dispatchSetStateInternal 함수에 위임
  • 개발 도구 통합: 업데이트가 스케줄링되면 타이머를 시작하고 개발 도구에 업데이트를 표시한다.
function dispatchSetStateInternal<S, A>(
  fiber: Fiber, // 업데이트할 컴포넌트를 나타내는 Fiber 노드
  queue: UpdateQueue<S, A>, // 해당 상태와 관련된 업데이트 큐
  action: A, // 새 상태 값 또는 이전 상태를 받아 새 상태를 반환하는 함수
  lane: Lane, // 해당 업데이트의 우선순위
): boolean {
  const update: Update<S, A> = { // 업데이트 객체 생성
    lane,                 // 업데이트 우선순위
    revertLane: NoLane,   // 취소 가능한 업데이트라면 사용
    action,               // 새 상태 값 or 상태 업데이트 함수
    hasEagerState: false, // 상태가 미리 계산되었는지 여부
    eagerState: null,     // 미리 계산된 값
    next: (null: any),    // 연결 리스트의 다음 업데이트
  };

  if (isRenderPhaseUpdate(fiber)) { 
    // 렌더링 중에 상태 업데이트가 발생한 경우: 렌더링 도중 상태 업데이트가 발생한 것을 특별한 큐인 renderPhaseUpdates 큐에 추가
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes && // 현재 컴포넌트에 다른 업데이트가 없고
      (alternate === null || alternate.lanes === NoLanes) // 작업 중인 버전인 alternate도 업데이트가 없다면
    ) {
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher = null;
        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 false; // 상태가 실제로 변경되지 않으면 불필요한 리렌더링을 피함
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {}
      }
    }

    // 일반적인 업데이트 처리
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane); // 업데이트 큐에 추가하고, 루트 Fiber 노드를 반환
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane); // 루트에서 업데이트 스케줄링
      entangleTransitionUpdate(root, queue, lane); // 트랜지션 관련 업데이트 처리
      return true; // 업데이트가 스케줄링되었음을 나타냄
    }
  }
  return false;
}

dispatchSetStateInternal는 상태 업데이트의 실제 처리를 담당한다.
dispatchSetStateInternal는 리액트 상태 업데이트의 핵심 부분으로, 상태 업데이트 객체를 생성하고 렌더링 단계 업데이트를 특별히 처리해준다. 또한 가능한 경우 상태 변경을 미리 계산하여 불필요한 리렌더링을 방지하고, 업데이트를 큐에 추가해서 컴포넌트 리렌더링을 스케줄링한다.

  • 리액트는 상태 업데이트를 즉시 처리하는게 아닌 업데이트 큐에 추가한다
    • 큐에 추가해서 여러 업데이트를 배치로 처리한다.
    • 우선순위에 따라 업데이트를 다르게 처리할 수 있다.
    • 업데이트 적용 시기를 리액트가 제어할 수 있다.

useState를 호출할 때의 흐름

사용자가 const [count, setCount] = useState(0);을 호출할 때 흐름을 정리해보자.

  1. 컴포넌트가 마운트 되면 useState가 호출되고, mountState가 실행된다. 그렇다면 상태 업데이트 함수인 setCount는 dispatchSetState.bind(null, fiber, queue)로 생성된다.
  2. 상태 업데이트가 발생하면? 상태 업데이트 함수인 setCount가 호출되며 바인딩된 dispatchSetState가 실행된다.
  3. dispatchSetState가 우선순위를 계산하고 dispatchSetStateInternal을 호출한다. 업데이트 객체를 만들고 렌더링 중이라면 특별한 큐에 추가하고, 아니라면 상태를 계산해서 변경이 있는지 확인한다. 변경이 있으면 업데이트 큐에 추가하고 컴포넌트 업데이트를 스케줄링한다.
  4. 리액트 스케줄러가 우선순위에 따라 컴포넌트를 리렌더링하고, updateState를 호출되고 큐에 있는 모든 업데이트를 적용한다.

updateState

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

updateState는 컴포넌트가 리렌더링(업데이트)될 때 호출되는 useState 구현이다.
컴포넌트가 리렌더링될 때 상태 업데이트를 처리한다.

updateState는 컴포넌트 리렌더링 중에 호출된다. useState 훅을 사용하는 컴포넌트가 다시 렌더링될 때 호출된다.

  • 첫 번째 렌더링(마운트): mountState를 사용하여 훅을 초기화한다.
  • 이후 렌더링(업데이트): updateState를 사용하여 현재 상태를 검색하고 필요한 경우 업데이트한다.

updateState는 간단하게 구현되어 있다.
반환하는 값은 updateReducer(basicStateReducer, initialState)이다. updateReducer 함수를 봐야 이해가 될거 같아서 찾아보았다.

updateReducer

리듀서 함수를 상태 변환을 처리하는 함수이다. updateReducer는 리액트 훅 시스템에서 상태 업데이트를 처리하는 핵심 함수다. 이 함수는 리렌더링 중 useReducer/useState가 호출될 때 실행된다.

updateReducer의 목적

  1. 대기 중인 모든 업데이트를 처리하여 새 상태 계산
  2. 처리할 수 없는 업데이트는 다음 렌더링을 위해 저장
  3. 최종적으로 계산된 상태와 디스패치 함수 반환
function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
}

function updateReducerImpl<S, A>(
  hook: Hook,           // 현재 작업 중인 훅
  current: Hook,        // 이전 렌더링 훅
  reducer: (S, A) => S, // 상태 변환을 처리하는 리듀서 함수
): [S, Dispatch<A>] {
    
  // 훅의 업데이트 큐가 있는지 확인 (큐가 없다면 조건부로 호출되었을 가능성이 높음)
  const queue = hook.queue;
  if (queue === null) {
    throw new Error(
      'Should have a queue. You are likely calling Hooks conditionally, ' +
        'which is not allowed. (https://react.dev/link/invalid-hook-call)',
    );
  }

  queue.lastRenderedReducer = reducer;

  // 대기 중인 업데이트 처리 준비 (이전 렌더링에서 처리되지 않은 업데이트와 새로 추가된 업데이트 가져오기)
  let baseQueue = hook.baseQueue;
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    if (baseQueue !== null) {
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }

  // 상태 계산 준비
  const baseState = hook.baseState;
  if (baseQueue === null) {
    hook.memoizedState = baseState;
  } else {
    // 업데이트 적용 
    const first = baseQueue.next;
    let newState = baseState; // 현재까지 계산된 새 상태
    let newBaseState = null;  // 다음 렌더링의 기본 상태가 될 값
    // newBaseQueueFirst, newBaseQueueLast: 다음 렌더링으로 넘겨질 처리되지 않은 업데이트들의 연결 리스트
    let newBaseQueueFirst = null;
    let newBaseQueueLast: Update<S, A> | null = null;
    let update = first;
    let didReadFromEntangledAsyncAction = false;
    do {
      // 업데이트 처리 로직
      const updateLane = removeLanes(update.lane, OffscreenLane); // 업데이트 우선순위
      const isHiddenUpdate = updateLane !== update.lane;

      // 현재 렌더링에서 업데이트를 건너뛸지 여부
      const shouldSkipUpdate = isHiddenUpdate
        ? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
        : !isSubsetOfLanes(renderLanes, updateLane);

      if (shouldSkipUpdate) { // 업데이트를 건너뛰는 경우
        const clone: Update<S, A> = { // 업데이트 복제
          lane: updateLane,
          revertLane: update.revertLane,
          action: update.action,
          hasEagerState: update.hasEagerState,
          eagerState: update.eagerState,
          next: (null: any),
        };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
        markSkippedUpdateLanes(updateLane); // 다음 렌더링에서 이 업데이트 처리하도록 설정
      } else { // 업데이트 적용하는 경우
        const revertLane = update.revertLane;
        if (!enableAsyncActions || revertLane === NoLane) {
          if (newBaseQueueLast !== null) {
            const clone: Update<S, A> = {
              lane: NoLane,
              revertLane: NoLane,
              action: update.action,
              hasEagerState: update.hasEagerState,
              eagerState: update.eagerState,
              next: (null: any),
            };
            newBaseQueueLast = newBaseQueueLast.next = clone;
          }

          if (updateLane === peekEntangledActionLane()) {
            didReadFromEntangledAsyncAction = true;
          }
        } else {
          if (isSubsetOfLanes(renderLanes, revertLane)) {
            update = update.next;
            if (revertLane === peekEntangledActionLane()) {
              didReadFromEntangledAsyncAction = true;
            }
            continue;
          } else {
            const clone: Update<S, A> = {
              lane: NoLane,
              revertLane: update.revertLane,
              action: update.action,
              hasEagerState: update.hasEagerState,
              eagerState: update.eagerState,
              next: (null: any),
            };
            if (newBaseQueueLast === null) {
              newBaseQueueFirst = newBaseQueueLast = clone;
              newBaseState = newState; 
            } else {
              newBaseQueueLast = newBaseQueueLast.next = clone;
            }
            currentlyRenderingFiber.lanes = mergeLanes(
              currentlyRenderingFiber.lanes,
              revertLane,
            );
            markSkippedUpdateLanes(revertLane);
          }
        }
        
        const action = update.action;
        if (update.hasEagerState) {
          newState = ((update.eagerState: any): S); // 미리 계산된 상태 사용
        } else {
          newState = reducer(newState, action); // 리듀서로 새 상태 계산
        }
      }
      update = update.next;
    } while (update !== null && update !== first);

    // 최종 상태 설정
    if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = (newBaseQueueFirst: any);
    }

    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
      if (didReadFromEntangledAsyncAction) {
        const entangledActionThenable = peekEntangledActionThenable();
        if (entangledActionThenable !== null) {
          throw entangledActionThenable;
        }
      }
    }

    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;

    queue.lastRenderedState = newState;
  }

  if (baseQueue === null) {
    queue.lanes = NoLanes;
  }

  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch]; // 상태와 디스패치 함수 반환
}

updateReducer는 훅의 업데이트 큐를 검증하고 설정한다. 그리고 대기 중 업데이트와 기존 업데이트를 병합해서 업데이트 큐를 순회하며 현재 렌더링에서 처리할 업데이트와 다음 렌더링으로 넘길 업데이트를 확인한다. 처리할 업데이트마다 리듀서를 적용해서 새 상태를 계산해주고, 최종 상태를 설정하고 다음 렌더링을 위한 정보를 저장해준다. 최종적으로 계산된 새 상태와 디스패치 함수를 반환해준다.

  • 코드를 읽다보면 다음 렌더링에 업데이트를 처리하도록 하는 로직도 있었다.
    • 리액트는 각 업데이트에 우선순위를 할당하고 현재 렌더링 우선순위에 맞는 업데이트만 처리한다.
    • 이를 통해 긴급한 업데이트(사용자 입력 등)를 먼저 처리하고 덜 중요한 업데이트(데이터 로딩)와 같은 것은 나중에 처리한다.
  • 코드에서 리듀서를 통해 새 상태를 업데이트하는 부분도 있었지만, 미리 계산한 새 상태를 넣어주는 부분도 있었다.
    • 리액트는 성능 최적화를 위해 상태 업데이트를 미리 계산할 수 있다.
    • 디스패치 시점에 미리 계산된 상태인 eagerState가 있다면 리듀서를 호출하지 않고 그 값을 사용한다.

rerenderState

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

rerenderState 함수는 컴포넌트가 강제로 리렌더링될 때 호출되는 useState 구현이다.
rerenderReducer 함수를 호출하여 상태를 다시 계산한다.

rerenderState가 호출되는 경우는 다음과 같다.

  • 상태 업데이트가 리렌더링 중에 일어났을 때
  • 동시성 모드에서 리렌더링이 중단되었다가 다시 시작될 때
  • Suspense나 오류 경계로 인한 특별한 리렌더링 시
  • 특정 최적화나 내부 상태 업데이트에 의한 강제 리렌더링 시

정리하기

1. 첫번째 렌더링 (마운트)

  • 컴포넌트 함수가 처음 실행된다.
  • mountState가 호출되어 초기 훅 설정이 이루어진다.
  • 초기 상태와 setState 함수가 반환된다.
  • 컴포넌트가 렌더링 된다.

2. 사용자가 상태 업데이트

  • 사용자가 버튼을 누르는 등의 동작을 통해 상태를 업데이트하려는 이벤트가 발생한다.
  • setState를 호출하게 되고 내부적으로 dispatchSetState가 호출된다.
  • 업데이트 객체가 큐에 추가되고 리렌더링이 예약된다.

3. 일반적인 리렌더링

  • 예약된 리렌더링이 시작된다.
  • 컴포넌트 함수는 다시 실행된다.
  • updateState가 호출되어 큐에 있는 업데이트를 처리한다.
  • 업데이트된 상태와 동일한 setState 함수가 반환된다.
  • 컴포넌트가 새 상태로 리렌더링된다.

4. 특별한 렌더링

  • 리액트 내부 매커니즘에 의해 특별한 리렌더링이 발생한다.
  • 이 경우 rerenderState가 호출되어 업데이트를 처리한다.
  • 최종 상태와 setState 함수가 반환된다.
  • 컴포넌트가 새 상태로 렌더링된다.

useState 흐름과 동작 원리 정리하기

useState의 전체 동작 흐름

1. 컴포넌트 마운트

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

컴포넌트에서 useState를 정의하고, 해당 컴포넌트가 처음으로 렌더링되면 리액트는 현재 상황에 맞는 디스패처를 설정한다.
이때 처음으로 렌더링되므로 mount되며, mountState를 호출한다.

  • 새 훅 객체를 생성하고 컴포넌트의 훅 리스트에 저장한다.
  • 이때 컴포넌트는 각자 훅 리스트를 가지는데, useState 뿐만 아니라 useEffect, useRef와 같은 훅이 순서대로 연결 리스트를 통해 관리된다.
  • 초기 값이 함수라면 함수를 실행해 값을 계산하고, 훅에 초기 상태를 저장한다. hook.memoizedState = hook.baseState = initialState
  • dispatchSetState를 현재 Fiber 큐에 바인딩하여 디스패치 함수(setState)를 생성한다.
  • [initialState, setState] 배열을 반환한다. 그렇다면 위 코드에서는 처음 count 값인 0과 setCount 함수가 반환되는 것이다.

2. 상태 업데이트

위에서 반환 받은 setState를 호출하면, 상태 업데이트가 발생한다.

  • setCount 함수는 내부적으로 바인딩된 dispatchSetState를 호출한다.
  • 현재 실행 컨텍스트에 기반하여 업데이트 우선순위를 결정한다.
  • dispatchSetStateInternal을 호출해 실제 업데이트 로직을 처리한다.
  • 업데이트 객체를 생성하는데 새 상태 값이나 업데이트 함수, 우선순위 정보가 담겨있다.
  • 최적화 시도를 위해 가능하면 상태를 미리 계산해서 eagerState에 저장해두고, 상태가 변경되지 않았다면 리렌더링 없이 종료한다.
  • 업데이트 객체를 큐에 추가하고, 컴포넌트 리렌더링을 예약한다.

3. 컴포넌트 리렌더링

  • updateState를 호출하여 업데이트 디스패처를 설정한다.
  • updateState는 내부적으로 updateReducer를 호출한다.
  • 이전 렌더링 훅과 매칭되는 현재 훅을 가져온다.
  • 대기 중인 모든 업데이트를 순서대로 처리하고, 각 업데이트에 리듀서(basicStateReducer)를 적용하여 새 상태를 계산한다. (우선순위에 따라 일부 업데이트는 다음 렌더링으로 미룰 수 있다.)
  • 계산된 새 상태는 훅의 memoizedState에 저장한다.
  • 새로운 상태와 상태 변경 함수를 반환한다.

리액트의 훅

위에서 언급했는데 리액트는 훅을 호출할 때 순서를 지켜야한다. 그래서 모든 렌더링마다 동일한 순서로 호출해야하는 규칙이 있다.

모든 렌더링마다 동일한 순서로 호출해야하기 때문에 조건문에 정의하는 것이 안된다. 어떤 조건에서는 훅을 호출하고 어떤 조건에서는 호출하지 않는다면 렌더링마다 동일한 순서로 훅이 호출되지 않는다.

리액트의 훅 식별 방식

리액트 훅이 렌더링 사이에 상태를 유지하려면 이전 렌더링 상태와 현재 렌더링의 훅 호출을 매칭해야한다.

훅으르 추적하기 위해 이름이나 명시적 식별자를 사용하지 않는다. 대신 렌더링 중에 훅이 호출되는 순서를 사용한다. 훅 데이터의 연결 리스트를 생성하여 컴포넌트와 연결하는데 각 훅의 상태는 이 리스트의 특정 위치에 저장된다.

Fiber.memoizedState → Hook1 → Hook2 → Hook3 → null

처음 렌더링될 때 훅을 만날 때마다 연결 리스트를 생성하고 이후 렌더링될 때 훅 호출을 하며 병렬로 이 리스트를 순회한다. 순서를 지켜야하기 때문에 조건문 안에 훅을 호출해서 조건에 따라 호출될 때도 있고 안 될때도 있게 설정하면 순서가 깨지기 떄문에 이런 식으로 사용할 수 없는 것이다.

훅 호출 순서가 변경되면?

위에서 언급한대로 훅에 대한 식별자가 없기 때문에 순서를 통해 알 수 있다고 했다.
그렇다면 이전 렌더링 상태가 잘못된 훅에 할당되어 상태 손실이 발생한다.

간단한 예시를 보자.

// 1. 첫 번째 렌더링 (count = 0)
function Counter() {
  const [count, setCount] = useState(0); // 훅 #1: state = 0
  // count = 0 이므로 조건부 훅 건너뜀
  const [name, setName] = useState("드뮴"); // 훅 #2: state = "드뮴"
  
  return <div>...</div>;
}

// 버튼 클릭 후 setCount(1) 호출
// 2. 두 번째 렌더링 (count = 1)
function Counter() {
  const [count, setCount] = useState(0); // 훅 #1: state = 1
  if (count > 0) {
    const [message, setMessage] = useState("채마야 공부해라"); // 훅 #2: "드뮴"을 받음
  }
  const [name, setName] = useState("드뮴"); // 훅 #3: 이전 상태가 존재하지 않음
  // 리액트가 충돌하거나 기본값을 사용
  
  return <div>...</div>;
}
  • 첫번째 렌더링에서는 useState로 count, name을 선언했다. 이때 리액트는 훅 리스트를 만들기 때문에 첫번째 count와 이어서 name을 연결 리스트로 저장한다. 그렇다면 각 상태는 0 - "드뮴"으로 저장된다.
  • 두번째 렌더링은 setCount가 예를 들어 호출되어서 발생했다고 가정한다. 그렇다면 코드를 보면 count는 1이 되고, 조건문이 하나 있는데 count가 1이 되어 조건문이 실행되므로 const [message, setMessage] = useState("채마야 공부해라");가 count 훅 뒤에 오게 되고, 그 다음으로 name이 호출되는 순서가 된다.
  • 그런데 두번째 렌더링에서는 이전 훅 리스트와 비교하며 업데이트를 진행하는데, 이전 렌더링에서 2번째 훅은 const [name, setName] = useState("드뮴");이었기에 두번째 렌더링에서 message의 useState는 이전 상태를 "드뮴"으로 받게된다.
  • 또한 이전 렌더링에서 훅의 연결 리스트는 길이가 2였기 때문에 3번째로 호출되는 훅은 이전 상태가 없으므로 충돌하거나 기본 값을 사용하며 예상치 않은 잘못된 상태 업데이트가 발생하는 것이다.

그렇다면 리액트는 왜 순서로 식별할까?

위와 같이 순서로 식별하기 때문에 순서를 유지해줘야했다. 그런데 훅마다 고유한 이름을 생성해 구별하면 더 편할거 같은데 왜 순서를 지키도록 해서 순서를 통해 상태 업데이트를 관리할까?

  • 고유한 이름을 부여하는 것도 쉽지 않다. 모든 훅 인스턴스에 이름을 지정해줘야하고 이 이름이 충돌할 버그도 생각해야한다.
  • 이름을 조회하는 것은 성능 비용을 추가해야한다. 또한 시스템을 더 복잡하게 만든다.

따라서 훅 규칙을 지켜서 사용만 하면 순서대로 식별하는 것은 간단한 방법이기 때문에, 이 방법을 채택한다.

훅 규칙

  • 반복문, 조건문, 중첩 함수 내부에서 훅을 호출할 수 없다. 모든 렌더링에서 같은 훅이 같은 순서로 호출되도록 보장해야한다.
  • 리액트 함수 컴포넌트나 커스텀 훅에서만 훅을 호출할 수 있다. 훅 리스트는 컴포넌트마다 있고 그렇기 때문에, 리액트 함수 컴포넌트나 커스텀 훅에서 호출해야만 훅 컨텍스트가 올바르게 설정된다.

리액트 훅 시스템은 훅이 렌더링마다 같은 순서로 호출된다는 가정하에 작동한다. 규칙을 어기면 상태가 엉망이 되고 잘못된 상태가 할당되며 애플리케이션 충돌로 이어질 수 있다.


클로저로 구현된 useState

클로저란?

클로저함수가 자신이 선언된 렉시컬 환경을 기억하고, 그 함수가 원래의 스코프 밖에서 실행될 때도 그 환경에 접근할 수 있는 능력을 말한다.

자바스크립트에서 함수 내부에 변수를 선언하게 되면 그 변수는 함수 내에서만 접근이 가능하다. 이를 함수 스코프라고 하는데, 렉시컬 스코프는 내부 함수가 자신을 포함하는 외부 함수에 선언된 변수에 접근할 수 있다는 의미다.

그래서 클로저는 내부 함수가 외부 함수 실행이 완료되어도 자신의 렉시컬 스코프에 대한 참조를 유지할 때 형성된다.


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 함수는 bind를 사용해 현재 렌더링 중인 컴포넌트와 상태 업데이트 큐가 바인딩 된다.
  • 바인딩된 함수는 setState(dispatch) 함수를 반환한다.
  • 바인딩 과정에서 클로저가 생성되고, 함수가 생성된 환경의 변수들인 currentlyRenderingFiber, queue에 대한 참조를 유지한다.

컴포넌트 함수가 완료되어도 setState 함수는 자신이 속한 컴포넌트와 상태 큐를 기억하고 있기 때문에 컴포넌트 상태 업데이트가 가능하다.

클로저 개념을 통해 useState를 설명하면 다음과 같다.

  • useState가 반환하는 상태 설정 함수인 setState는 클로저를 활용해 정보를 기억한다. 기억하는 정보는 어떤 컴포넌트에 속하는지, 어떤 상태 큐를 업데이트 해야하는지에 대한 정보다.
  • 위와 같은 정보를 기억하기 때문에 setState가 호출될 때 내가 어떤 컴포넌트의 어떤 상태를 업데이트 해야하는지를 알 수 있다. 클로저를 이용해 이 정보를 함수와 함께 기억해 언제 어디서 호출되든 올바른 컴포넌트와 상태를 참조할 수 있다.

왜 클로저를 사용할까?

  1. 컴포넌트 인스턴스와의 상태 연결을 유지하기 위해서다.
    클로저를 사용하면 컴포넌트 인스턴스인 Fiber와 상태 사이의 연결을 유지할 수 있다. 함수형 컴포넌트는 매 렌더링마다 새로 실행되기 때문에 클로저가 없다면 어느 컴포넌트에 속하는지 정보를 유지하기 어렵다.

클로저는 함수와 그 함수가 선언된 환경의 조합이다. 리액트에서 useState가 반환하는 상태 설정 함수인 setState는 어떤 컴포넌트에 속하는지, 어떤 상태 큐를 업데이트해야하는지에 대한 정보를 기억한다. 즉, 어떤 컴포넌트에 속하는지는 Fiber 객체를 기억하는 것이고 이를 기억하고 언제 어디서든 호출되어도 올바른 컴포넌트에서 상태 업데이트를 수행한다.

  1. 캡슐화와 은닉성의 특징이 있다.
    클로저를 사용하면 상태 관리 로직을 캡슐화하고 외부로부터 숨길 수 있다. 개발자는 상태 값과 함수만 사용하면 되고 복잡한 내부 동작에 대해 신경 쓸 필요가 없다.

클로저를 통해 상태가 오직 setState를 통해 변경되도록 보장한다. 단방향 데이터 흐름의 원칙을 강화한다. 또한 상태 값을 리액트 내부에 저장하고 컴포넌트는 읽기 전용 복사본만 제공되기 때문에 상태를 보호할 수 있다.


참고 자료

profile
안녕하세오

7개의 댓글

comment-user-thumbnail
4일 전

자세한 서명 감사드립니다 👍

1개의 답글
comment-user-thumbnail
4일 전

채마가 등장햇다

1개의 답글

관련 채용 정보