[React] Hook 낯설게하기 1

thru·2024년 8월 29일
2

React-Hook

목록 보기
1/2

파훅이다.

React를 처음 배울 때, useRefuseEffect 등 리액트 기본 훅도 같이 알게된다. 익숙하게 느껴지다보니 useState처럼 거부감없이 사용하고 있었는데, 공식문서에서 두 훅 모두 필요할 때만 사용하라는 경고가 있는 것을 이제야 알게되어 훅들에 대한 지식을 점검해보려 한다.

먼저 서로 유사한 훅인 useStateuseReducer에 대해 알아본다.


React Hook

훅들에 대해 세부적으로 알아보기 전에 리액트의 렌더링 과정을 먼저 대략적으로 훑어본다. 컴포넌트의 실행은 리액트의 VDOM 조정을 담당하는 reconciler에서 이루어진다. 조화시킨다는 의미에 맞게 reconciler는 컴포넌트의 상태변화를 VDOM에 반영시키고, 최종 DOM 적용 작업을 renderer가 수행할 수 있도록 scheduler를 통해 예약한다. VDOM은 DOM과 유사하게 트리구조로 구성되어 한 요소가 렌더링 될 때 자식 요소도 리렌더링의 대상이 된다. 영향 받지 않는 부모 요소는 로드 감소를 위해 재사용 작업인 bailout을 거친다. concurrent mode에선 렌더링 간 우선순위를 비교해서 열위인 작업을 중간에 취소하고 대기시키는 분류 과정도 수행한다. 이벤트 안에서 일어난 상태변화는 reconciler에 신호를 주어 컴포넌트가 실행될 수 있도록 한다.

훅은 함수 컴포넌트 안에서 호출되므로 훅의 동작은 함수 컴포넌트가 실행되는 시점에서 시작한다. 이때 훅의 구현체는 mount 시점과 update 시점이 다르도록 reconciler에서 별도로 주입한다.

// react-reconciler/src/ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  /**@ 생략 **/
  currentlyRenderingFiber = workInProgress;  // << (5)
  
  workInProgress.memoizedState = null;  // << (3)
  workInProgress.updateQueue = null;
  
  /**@ 생략 **/
  } else {
    ReactSharedInternals.H =  // << (2)
      current === null || current.memoizedState === null  // << (1)
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }

  let children = __DEV__
    ? callComponentInDEV(Component, props, secondArg)
    : Component(props, secondArg);
  /**@ 생략 **/
}

const HooksDispatcherOnMount: Dispatcher = {
  /**@ 일부 생략 **/
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useRef: mountRef,
  useState: mountState,
};

const HooksDispatcherOnUpdate: Dispatcher = {

  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useRef: updateRef,
  useState: updateState,
};

(1) current는 구성이 완료되어 DOM에 적용된 VDOM 트리를 의미한다. currentnull이라는 것은 첫 렌더링을 의미하므로 mount용 훅을 주입한다. 두번째 렌더링부턴 업데이트용 훅 구현체를 사용한다.

(2) 구현체가 들어가는 ReactSharedInternals는 리액트 모듈간 의존성을 줄이기 위해 분리되어 상황에 따라 변하는 모듈이다. 우리가 export해서 사용하는 훅 함수가 여기서 구현체를 가져온다.

/**@ react/src/ReactHooks.js **/
function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  return ((dispatcher: any): Dispatcher);
}

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

(3) 컴포넌트 실행 전에 속성을 초기화하고 있는 workInProgresscurrent와 반대 개념의 VDOM 트리로, 다음 DOM에 적용되기 위해 현재 렌더링 작업이 이루어진다. 속성 중 memoizedState는 상태와 연관이 있는 훅들의 리스트가 저장되고 updateQueueuseEffect처럼 이펙트를 유발하는 훅의 실행 여부가 기록된다.

useState

동작원리

useState는 당연하게도 상태를 관리하는 훅이므로 memoizedState 속성에 결과값이 저장된다.

/**@ react-reconciler/src/ReactFiberHooks.js **/
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;  // << (4)
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;  // << (6)
  }
  return workInProgressHook;
}

(4) 컴포넌트 안에서 처음으로 사용되는 훅이라면 currentlyRenderingFibermemoizedState에 추가된다. (5) currentlyRenderingFiber는 방금 전 renderWithHooks에서 렌더링의 대상이 되는 workInProgress Fiber를 저장했었다.

(6) 이후 사용되는 훅들은 hook 객체의 next 속성에 Linked List 형태로 추가된다.

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    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;
}

빈 훅 객체를 만들어 리스트에 연결했으니 이젠 내용을 채울 차례이다. 마운트 구현체는 호출 시 전달된 초기 상태값을 설정하고 queue 속성을 초기화한다.

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);  // << (7)
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

(7) queuedispatchSetState라는 함수에 bind되어 사용되는데 이 함수가 useState가 반환하는 setState이다.

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

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

  if (isRenderPhaseUpdate(fiber)) {  // << (8)
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    /**@ 뒤에서 설명 **/
  }
}

dispatchSetState는 하나의 상태 변화에 대해 update 객체를 생성한다. update 객체는 action을 인자에서 가져오는데, 인자 중 fiberqueue는 방금 mountState에서 bind된 값이므로 action이 우리가 setState에 전달하는 값 또는 업데이트 콜백임을 알 수 있다.

(8) if 문의 조건으로 있는 render phase update란 이벤트나 promise 등으로 촉발되지 않고 렌더링 중에 발생한 업데이트를 의미한다.

function enqueueRenderPhaseUpdate<S, A>(
  queue: UpdateQueue<S, A>,
  update: Update<S, A>,
): void {
  // This is a render phase update. Stash it in a lazily-created map of
  // queue -> linked list of updates. After this render pass, we'll restart
  // and apply the stashed updates on top of the work-in-progress hook.
  didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate =
    true;
  const pending = queue.pending;
  if (pending === null) {
    // This is the first update. Create a circular list.
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }
  queue.pending = update;  // << (9)
}

(9) update 객체는 훅의 queue 객체 pending 속성에 원형 Linked List 형태로 연결된다. 함께 didScheduleRenderPhaseUpdateDuringThisPass라는 플래그도 true로 설정하는 것을 볼 수 있는데 이는 현재 컴포넌트 호출 이후에 활용된다.

export function renderWithHooks<Props, SecondArg>(
  /** @ 처음에 본 부분 **/
  let children = __DEV__
    ? callComponentInDEV(Component, props, secondArg)
    : Component(props, secondArg);

  // Check if there was a render phase update
  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    // Keep rendering until the component stabilizes (there are no more render
    // phase updates).
    children = renderWithHooksAgain(
      workInProgress,
      Component,
      props,
      secondArg,
    );
  }

일단 큐에 업데이트를 추가한 상태로 현재 컴포런트의 호출을 마친 뒤, render phase update가 있었다면 다시 현재 컴포넌트의 렌더링을 수행한다. 이 과정을 통해 불필요한 리렌더링이 다음 단계로 이어지는 것을 막는다.

function dispatchSetState<S, A>(
  /**@ 앞에서 본 부분 **/
  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) {
        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)) {  // << (10)
            // 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);  // << (11)
            return;
          }
        /**@ 생략 **/
      }
    }

    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);  // << (12)
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
    }
  }

render phase update가 아닐 때는 좀 더 긴 과정을 거친다. (10) 주석으로 잘 설명되어 있는데 예정된 업데이트가 없을 땐 현재 상태값과 업데이트될 상태값을 비교해서 같으면 리렌더링을 계획하지 않는다.

eagerly bailout이라는 용어를 사용하는 이유는 현재 요소가 상태 비교 후 bailout 과정의 대상이 되기 때문인 것으로 보인다. bailout은 workInProgress 트리가 형성되기 전에 current의 노드를 똑같이 복제하는 과정이다. 원래 업데이트가 일어나는 노드는 변경점이 생기므로 bailout되었다고 하지 않지만, 이전 상태와 같다면 사실상 bailout이라고 호칭하는 것 같다.

(11) 리렌더링이 필요 없는 상황에서도 큐에 업데이트를 연결한다. 이는 concurrent 렌더링으로 인해 업데이트의 순서가 변할 수 있기 때문이다.

(12) bailout이 아닌 경우 업데이트를 큐에 쌓고 root에 업데이트를 스케쥴한다.

/**@ react-reconciler/src/ReactFiberWorkLoop.js **/
export function scheduleUpdateOnFiber(
  root: FiberRoot,
  fiber: Fiber,
  lane: Lane,
) {
  /**@ 생략 **/
    ensureRootIsScheduled(root);
  /**@ 생략 **/
}
/**@ react-reconciler/src/ReactFiberRootScheduler.js **/
export function ensureRootIsScheduled(root: FiberRoot): void {
    // Add the root to the schedule
  if (root === lastScheduledRoot || root.next !== null) {
    // Fast path. This root is already scheduled.
  } else {
    if (lastScheduledRoot === null) {
      firstScheduledRoot = lastScheduledRoot = root;
    } else {
      lastScheduledRoot.next = root;
      lastScheduledRoot = root;
    }
  }
  
  /**@ 생략 **/
  
  if (!enableDeferRootSchedulingToMicrotask) {
    // While this flag is disabled, we schedule the render task immediately
    // instead of waiting a microtask.
    // TODO: We need to land enableDeferRootSchedulingToMicrotask ASAP to
    // unblock additional features we have planned.
    scheduleTaskForRootDuringMicrotask(root, now());
  }
}

function scheduleTaskForRootDuringMicrotask(
  root: FiberRoot,
  currentTime: number,
): Lane {
  /**@ 생략 **/
      const newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root),
    );  // << (13)
}

(13) root는 하위 트리에 업데이트가 있다면 스케쥴러를 통해 task에 callback을 등록한다. Callback은 대기열에 있다가 콜스택이 비면 렌더링 작업을 수행한다.

위 내용은 mount 시점이라서 update queue를 소비하는 과정이 없다. update 시점은 useReducer에서 확인한다.

새로고친 점

Render phase update를 살펴볼 때 setState를 컴포넌트 로직 안에서 직접적으로 사용한다는 것이 어색하게 느껴졌다. 컴포넌트 로직은 순수 함수여야한다는 생각과 불필요한 리렌더링을 촉발할 수 있을 것 같다는 막연한 거리낌이 있었다.

사실 위에서 살펴본 것처럼 불필요한 리렌더링은 최소화되어 부담되지 않는다. render phase update는 업데이트 객체를 큐에 추가하고 해당 컴포넌트만 리렌더링한다. 하위 컴포넌트를 모두 렌더링하고 바로 버리는 과정은 거치지 않는다. 또한 렌더링 중 setState가 사용되어도 조건만 알맞게 세운다면 순수함수를 유지할 수 있다. 이를 통해 useEffect 등 escape hatch로 처리하던 업데이트를 render phase 업데이트로 대체한다면 리렌더링을 제거할 수 있으므로 효율적이다. 리액트 공식문서의 예시가 잘 설명해준다.

export default function CountLabel({ count }) {
  const [prevCount, setPrevCount] = useState(count);
  const [trend, setTrend] = useState(null);
  if (prevCount !== count) {
    setPrevCount(count);
    setTrend(count > prevCount ? 'increasing' : 'decreasing');
  }
  return (
    <>
      <h1>{count}</h1>
      {trend && <p>The count is {trend}</p>}
    </>
  );
}

Props로 전달받은 count의 이전 값을 비교해서 렌더링할 텍스트를 달리하는 예시이다. count는 상위 컴포넌트에서 사용자가 버튼 클릭으로 증감시킬 수 있는 상태값이다. count가 업데이트되며 리렌더링이 일어나면 조건문 안의 setState도 실행된다. 이전 값과 다를 때만 업데이트한다는 조건이 붙어있으므로 무한 렌더링에 빠지지 않을 수 있다.

만약 같은 기능의 컴포넌트를 내 원래 습관대로 구현했다면 다음과 같다.

export default function CountLabel({ count }) {
  const prevCount = useRef(count);
  const [trend, setTrend] = useState(null);
  
  useEffect(() => {
    setTrend(count > prevCount.current ? 'increasing' : 'decreasing');
    prevCount.current = count;
  }, [count, setTrend])
  
  return (
    <>
      <h1>{count}</h1>
      {trend && <p>The count is {trend}</p>}
    </>
  );
}

이 경우엔 리렌더링으로 DOM 반영이 두 번이나 되어 비효율적일 것이 자명하다.


useReducer

잘 알려진 사실이지만 useStateuseReducer에 기본 리듀서를 사용해서 구현되어있다.

/**@ react-reconciler/src/ReactFiberHooks.js **/
function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState);
}

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;  // << (14)
}

(14) 기본 리듀서는 setState에 전달한 값이나 함수로 state를 단순히 설정하는 기능만 한다.

마운트용 구현체와 dispatch 함수는 각각 따로 존재하지만 로직이 비슷하고, 업데이트 구현체는 아예 리듀서의 것을 같이 사용한다. 마운트는 useState에서 살펴봤으므로 useReducer에서는 업데이트를 위주로 살펴본다.

동작원리

function updateWorkInProgressHook(): Hook {
  // This function is used both for updates and for re-renders triggered by a
  // render phase update. It assumes there is either a current hook we can
  // clone, or a work-in-progress hook from a previous render pass that we can
  // use as a base.
  
  /**@ 생략: nextCurrentHook, nextWorkInProgressHook 가져오기 **/

  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.
    
    if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;
      /**@ 생략 **/
    }

    currentHook = nextCurrentHook;

    const newHook: Hook = {  // << (15)
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

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

(15) mountWorkInProgressHook과 달리 기존 노드의 hook 값을 재활용해서 사용한다.

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;

  queue.lastRenderedReducer = reducer;

  // The last rebase update that is NOT part of the base state.
  let baseQueue = hook.baseQueue;

  // The last pending update that hasn't been processed yet.
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    // We have new updates that haven't been processed yet.
    // We'll add them to the base queue.
    if (baseQueue !== null) {  // << (16)
      // Merge the pending queue and the base queue.
      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) {
    // If there are no pending updates, then the memoized state should be the
    // same as the base state. Currently these only diverge in the case of
    // useOptimistic, because useOptimistic accepts a new baseState on
    // every render.
    hook.memoizedState = baseState;
    // We don't need to call markWorkInProgressReceivedUpdate because
    // baseState is derived from other reactive values.
  } else {
    // We have a queue to process.
    const first = baseQueue.next;
    let newState = baseState;

    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast: Update<S, A> | null = null;
    let update = first;
    do {
      /**@ 생략: 우선순위 관련 Lane 처리 작업 **/
      if (shouldSkipUpdate) {
        // Priority is insufficient. Skip this update. If this is the first
        // skipped update, the previous update/state is the new base
        // update/state.
        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;  // << (18)
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
      } else {
        if (!enableAsyncActions || revertLane === NoLane) {  // << (17)
          // This is not an optimistic update, and we're going to apply it now.
          // But, if there were earlier updates that were skipped, we need to
          // leave this update in the queue so it can be rebased later.
          if (newBaseQueueLast !== null) {  // << (19)
            const clone: Update<S, A> = {
              // This update is going to be committed so we never want uncommit
              // it. Using NoLane works because 0 is a subset of all bitmasks, so
              // this will never be skipped by the check above.
              lane: NoLane,
              revertLane: NoLane,
              action: update.action,
              hasEagerState: update.hasEagerState,
              eagerState: update.eagerState,
              next: (null: any),
            };
            newBaseQueueLast = newBaseQueueLast.next = clone;  
          }
        } else {
          /**@ 생략: 낙관적 업데이트 관련 처리 작업, clone을 생성하지 않는다 **/  // << (19)
        }

        // Process this update.
        const action = update.action;
        if (update.hasEagerState) {
          // If this update is a state update (not a reducer) and was processed eagerly,
          // we can use the eagerly computed state
          newState = ((update.eagerState: any): S);
        } else {
          newState = reducer(newState, action);  // << (17)
        }
      }
      update = update.next;
    } while (update !== null && update !== first);

    if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = (newBaseQueueFirst: any);
    }

    // Mark that the fiber performed work, but only if the new state is
    // different from the current state.
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();

      /**@ 생략: suspense 관련 처리 **/
    }

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

    queue.lastRenderedState = newState;
  }

  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

마운트 때는 모든 업데이트를 hook 객체의 queue.pending 속성에만 추가했다. 업데이트 때는 baseStatebaseQueue라는 속성에 업데이트 객체를 추가하거나 소비한다.

baseQueue는 우선순위에 밀려 대기중인 업데이트들의 큐이다. (16) pendingQueue는 일단 baseQueue에 병합된 뒤 루프문 안쪽 처리 과정을 거친다. (17) UI의 반응성을 위해 바로 상태에 반영해야하는 동기적 업데이트는 현재 상태값인 memoizedState를 먼저 업데이트한다.

(18) baseState는 동기적 업데이트가 적용되기 직전의 memoizedState값을 저장한다. 이는 rebase 과정에서 기준으로 사용된다. 동기적 업데이트가 미리 적용되었더라도 업데이트가 큐에 추가된 순서로 반영되어야 개발자가 의도한 대로 최종 상태는 나타날 수 있다. baseQueue에 대기중이던 업데이트는 우선순위가 충족되어 반영될 때 memoizedState가 아닌 baseState를 기반으로 상태를 갱신한다.

위 내용까지만 보면 먼저 반영되었던 동기적 업데이트의 결과가 버려질 것 같지만, 동기적 업데이트는 미리 반영될 때 baseQueue에서 삭제되지 않는다. 나중에 순서에 맞게 변화된 memoizedState값 기반으로 다시 업데이트를 반영하면서 rebase가 진행된다. (19) 동기적 업데이트 시점에 대기중인 큐가 없어 바로 상태에 적용되거나 낙관적 업데이트일 때만 baseQueue에서 삭제될 수 있다.

새로고친 점

Eagerly bailout || Bailout

dispatchSetState와 같은 역할을 하는 dispatchReducerAction 코드를 보면 eagerly bailout 과정만 쏙 빠져있다.

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

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

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    /**@ setState에서 eagerlyBailout 하던 곳! **/
    
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
    }
  }
}

eagerly bailout 과정은 사용했던 리듀서로 렌더링 전에 상태를 미리 계산하고 기존값과 같으면 리렌더링을 스케쥴하지않는 최적화 과정이었다.

/**@ dispatchSetState **/
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;
}

setState의 공식문서를 보면 이전 상태값 비교 최적화에 대한 내용이 있다.

Object.is 비교를 통해 새롭게 제공된 값과 현재 state를 비교한 값이 같을 경우, React는 컴포넌트와 해당 컴포넌트의 자식 요소들의 리렌더링을 건너뜁니다. 이것은 최적화에 관련된 동작으로써 결과를 무시하기 전에 컴포넌트가 호출되지만, 호출된 결과가 코드에 영향을 미치지는 않습니다. - setState/caveats

조건문의 isObject.is의 폴리필 함수이다.

여기서 개인적으로 착각을 했는데, Object.is와 리렌더링을 건너뛴다는 표현에 매몰되어 위 설명이 eagerly bailout 과정을 의미하는 것이라고 생각했다. 하지만 useReducer 문서에도 같은 설명이 존재한다. 그리고 eagerly bailout 과정은 컴포넌트를 호출하지도 않는다.

사실 위 설명은 updateReducer에서 기존 상태와 업데이트 소비 후 상태를 비교하는 부분에 해당된다.

/**@ updateReducer **/
    // Mark that the fiber performed work, but only if the new state is
    // different from the current state.
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();

이는 직접적으로 fiber를 복제하는 과정인 bailout에 관련된 코드이다.

/**@ react-reconciler/src/ReactFiberBeginWork.js **/
export function markWorkInProgressReceivedUpdate() {
  didReceiveUpdate = true;
}
function updateFunctionComponent() {
  /**@ 생략 **/
  nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    nextProps,
    context,
    renderLanes,
  );

  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }
  /**@ 생략 **/
}

bailout 과정으로 업데이트 결과가 무시되기 전에 renderWithHooks로 컴포넌트 호출이 일어난다. 따라서 공식문서의 내용은 eagerly bailout이 아니라 일반 bailout을 의미하는 것으로 보인다.

useReducer에 eagerly bailout이 없는 이유

dispatchSetStateeagerly bailout 부분 주석에 설명된 대로 eagerly bailout은 이전에 사용된 리듀서가 변하지 않았다는 걸 전제로 한다. 그런데 useReducer에 전달되는 리듀서는 컴포넌트 render phase에서 props 같은 외부 요인이 결합된 채로 사용되는 케이스가 있어왔다고 한다. 이로 인해 eagerly bailout 과정에서 불필요한 상태 비교나 메모리 누수 등의 문제가 우려되었던 것으로 보인다. 때문에 useReducer에서 eagerly bailout 과정은 제거되었고, 외부 요인이 결합할 수 없는 기본 리듀서를 사용한 useState에만 남았다.

useReducer는 원래도 성능이 아닌 가독성과 개발 용이성을 위한 것이었는데, 코드 상으로도 useState와 성능 차이가 있다는 점을 알게되었다. useState에서 useReducer로의 전환은 깊은 고민이 선행되어야 할 것 같다.


참조

profile
프론트 공부 중

0개의 댓글