Amada Coding Club

[React Core Deep Dive] How does useState() work internally in React? 본문

Mash-Up/Study

[React Core Deep Dive] How does useState() work internally in React?

아마다회장 2024. 6. 11. 18:05

JSer의 How does useState() work internally in React의 내용을 직역했습니다.

 

React의 useState()에 익숙하실 겁니다. 기본 카운터 앱은 useState()를 통해 컴포넌트에 상태를 추가하는 방법을 보여줍니다. 이번 에피소드에서는 소스코드를 살펴봄으로써 내부적으로 useState()가 어떻게 작동하는지 알아보겠습니다.

import { useState } from 'react';

export default function App() {
  const [count, setCount] = useState(0)
  return (
    <div>
    <button onClick={() => setCount(count => count + 1)}>click {count}</button>
    </div>
  );
}

 

1. useState in initial render

초기 렌더링은 매우 간단합니다.

 

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  /*
    새로운 hook이 생성됩니다.
  */

  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  /*
    memoizedState는 hook의 실제 state 값을 보유합니다.
  */

  const queue: UpdateQueue<S, BasicStateAction<S>> = {
  /*
    UpdateQueue는 향후 상태 업데이트를 보관하는 곳입니다. 
    상태를 설정할 때 상태 값이 바로 업데이트되지 않는다는 점에 유의하세요. 
    이는 업데이트의 우선순위가 다를 수 있고, 즉시 처리할 필요가 없기 때문입니다
    따라서 업데이트를 숨겨두었다가 나중에 처리해야 합니다.
  */

    pending: null,
    lanes: NoLanes,
    /*
      레인이 우선순위입니다.
    */

    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  /*
    이 큐는 훅에 대한 업데이트 큐라는 것을 명심하십시오.
  */

  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchSetState.bind(
  /*
    이 dispatchSetState()는 실제로 상태 설정자에 대해 얻는 것입니다.
    현재 Fiber에 바인딩되어 있음을 알 수 있습니다.
  */

    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
  /*
    다음은 useState()에서 얻을 수 있는 익숙한 구문입니다.
  */

}

 

2. setState()에서 일어나는 일들

위의 코드에서 setState()가 실제로는 내부적으로 바인딩된 dispatchSetState()라는 것을 알 수 있습니다.

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),
  };
  /*
    여기에 저장할 업데이트 개체가 있습니다.
  */

  if (isRenderPhaseUpdate(fiber)) {
  /*
    렌더링 중에 setState를 사용할 수 있는데, 
    이는 유용한 패턴이지만 무한 렌더링으로 이어질 수 있으니 주의하세요.
  */
  
  //❤️ 렌더링 중에 setState를 호출하는 경우에 업데이트 큐에 값을 넣음
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
  
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
      /*
      이 상태 검사는 조기 bailout을 위한 것으로, 
      동일한 상태를 설정하면 아무것도 하지 않는 것을 의미합니다. 
      bailout은 하위 트리의 재렌더링을 건너뛰기 위해 더 깊이 들어가지 않는 것을 의미하며, 
      여기서는 재렌더링 예약을 피하기 위한 early bailout입니다. 
      ❤️ 즉 early bailout은 리렌더링 자체를 막는 걸 의미한다.
      하지만 여기서 조건은 사실 해킹이며, 
      필요 이상으로 엄격한 규칙으로 React가 최선을 다해 재렌더링 예약을 피하려고 하지만 보장하지는 않습니다. 
      */
    ) {
      // 현재 대기열이 비어 있으므로 렌더링 단계로 들어가기 전에 다음 상태를 열심히 계산할 수 있습니다. 
      // 새 상태가 현재 상태와 같으면 완전히 빠져나갈 수 있습니다.
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher;
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          // 열심히 계산한 상태와 이를 계산하는 데 쓰이는 리듀서를 업데이트 객체에 숨깁니다. 
          // 렌더링 단계에 진입할 때까지 리듀서가 변경되지 않았다면 
          // 리듀서를 다시 호출하지 않고도 eager 상태를 사용할 수 있습니다.
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            // 빠른 경로. React를 다시 렌더링하도록 스케줄링하지 않고 빠져나갈 수 있습니다. 
            //나중에 컴포넌트가 다른 이유(리듀서가 변경된 경우)로 다시 렌더링되는 경우 
            // 이 업데이트를 다시 베이스해야 할 수도 있습니다. 
            // TODO: 이 경우에도 전환을 얽어야 하나요?
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return;
            /*
              이 반환은 업데이트가 예약되지 않도록 합니다.
            */

          }
        } catch (error) {
          // 오류를 억제합니다. 렌더링 단계에서 다시 발생합니다.
        } finally {
          if (__DEV__) {
            ReactCurrentDispatcher.current = prevDispatcher;
          }
        }
      }
    }
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    /*
      이렇게 하면 업데이트가 숨겨집니다. 
      업데이트는 실제 재렌더링이 시작될 때 처리되어 파이버에 첨부됩니다.
    */

    if (root !== null) {
      const eventTime = requestEventTime();
      scheduleUpdateOnFiber(root, fiber, lane, eventTime);
      /*
        이렇게 하면 다시 렌더링이 예약되며, 다시 렌더링이 즉시 수행되지 않습니다. 
        실제 스케줄링은 React 스케줄러에 따라 달라집니다.
      */

      entangleTransitionUpdate(root, queue, lane);
    }
  }
}

업데이트 객체가 어떻게 처리되는지 자세히 살펴보겠습니다.

// 렌더링이 진행 중이고 동시 이벤트에서 업데이트를 받으면 
// 현재 렌더링이 끝나거나 중단될 때까지 기다렸다가 파이버/hook queue에 추가합니다.
// 이 배열로 푸시하면 나중에 queue, 파이버, 업데이트 등에 액세스할 수 있습니다.
const concurrentQueues: Array<any> = [];
let concurrentQueuesIndex = 0;
let concurrentlyUpdatedLanes: Lanes = NoLanes;
export function finishQueueingConcurrentUpdates(): void {
  /*
  이 함수는 리렌더링의 초기 단계 중 하나인 prepareFreshStack() 내부에서 호출되며, 
  리렌더링이 실제로 시작되기 전에 모든 상태 업데이트가 저장됩니다.
  */

  const endIndex = concurrentQueuesIndex;
  concurrentQueuesIndex = 0;
  concurrentlyUpdatedLanes = NoLanes;
  let i = 0;
  while (i < endIndex) {
    const fiber: Fiber = concurrentQueues[i];
    concurrentQueues[i++] = null;
    const queue: ConcurrentQueue = concurrentQueues[i];
    concurrentQueues[i++] = null;
    const update: ConcurrentUpdate = concurrentQueues[i];
    concurrentQueues[i++] = null;
    const lane: Lane = concurrentQueues[i];
    concurrentQueues[i++] = null;
    if (queue !== null && update !== null) {
      //🔥
      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;
      //🔥
      /*
      앞서 언급했던 hook.queue를 기억하시나요? 
      여기에서는 저장된 업데이트가 마침내 여기에 파이버에 첨부되어 
      처리될 준비를 하는 것을 볼 수 있습니다.
      */

    }
    if (lane !== NoLane) {
      markUpdateLaneFromFiberToRoot(fiber, update, lane);
      /*
        또한 파이버 노드 경로를 dirty로 표시하는 이 함수 호출에 주의하세요. 
        자세한 내용은 조정에서 React bailout이 작동하는 방식에 있는 다이어그램을 참조하세요.
      */

    }
  }
}
function enqueueUpdate(
  fiber: Fiber,
  queue: ConcurrentQueue | null,
  update: ConcurrentUpdate | null,
  lane: Lane,
) {
  // Don't update the `childLanes` on the return path yet. If we already in
  // the middle of rendering, wait until after it has completed.
  concurrentQueues[concurrentQueuesIndex++] = fiber;
  concurrentQueues[concurrentQueuesIndex++] = queue;
  concurrentQueues[concurrentQueuesIndex++] = update;
  concurrentQueues[concurrentQueuesIndex++] = lane;
  /*
    내부적으로 업데이트는 message queue와 같은 리스트에 보관되며 일괄 처리됩니다.
  */

   concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
  // 파이버의 `레인` 필드는 일부 장소에서 작업이 예정되어 있는지 확인하고 
  // 긴급 bailout을 수행하는 데 사용되므로 즉시 업데이트해야 합니다.
  // TODO: 대신 '공유' queue로 옮겨야 할 것 같습니다.
  fiber.lanes = mergeLanes(fiber.lanes, lane);
  const alternate = fiber.alternate;
  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane);
  }
  /*
    현재 fiber와 대체 fiber가 모두 dirty로 표시되어 있습니다. 
    이 주의 사항을 이해하는 것이 중요합니다.
  */

}
export function enqueueConcurrentHookUpdate<S, A>(
  fiber: Fiber,
  queue: HookQueue<S, A>,
  update: HookUpdate<S, A>,
  lane: Lane,
): FiberRoot | null {
  const concurrentQueue: ConcurrentQueue = (queue: any);
  const concurrentUpdate: ConcurrentUpdate = (update: any);
  enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane);
  return getRootForUpdatedFiber(fiber);
}
function markUpdateLaneFromFiberToRoot(
  sourceFiber: Fiber,
  update: ConcurrentUpdate | null,
  lane: Lane,
): void {
  // Update the source fiber's lanes
  sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
  let alternate = sourceFiber.alternate;
  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane);
  }
  /*
    현재 파이버와 대체 파이버 모두에 대해 레인이 업데이트된다는 점을 기억하세요. 
    dispatchSetState()가 소스 파이버에 바인딩되어 있으므로 
    상태를 설정할 때 항상 현재 파이버 트리가 업데이트되지 않을 수 있다는 점을 언급했습니다. 
    둘 다 설정하면 모든 것이 올바르게 작동하지만 부작용이 있으므로 caveat 섹션에서 다시 다룰 것입니다.
  */

  // 상위 경로를 루트로 이동하여 하위 레인을 업데이트합니다.

  let isHidden = false;
  let parent = sourceFiber.return;
  let node = sourceFiber;
  while (parent !== null) {
    parent.childLanes = mergeLanes(parent.childLanes, lane);
    alternate = parent.alternate;
    if (alternate !== null) {
      alternate.childLanes = mergeLanes(alternate.childLanes, lane);
    }
    if (parent.tag === OffscreenComponent) {
      const offscreenInstance: OffscreenInstance = parent.stateNode;
      if (offscreenInstance.isHidden) {
        isHidden = true;
      }
    }
    node = parent;
    parent = parent.return;
  }
  if (isHidden && update !== null && node.tag === HostRoot) {
    const root: FiberRoot = node.stateNode;
    markHiddenUpdate(root, update, lane);
  }
}
export function scheduleUpdateOnFiber(
  root: FiberRoot,
  fiber: Fiber,
  lane: Lane,
  eventTime: number,
) {
  // ...
    ensureRootIsScheduled(root, eventTime);
    /*
      이것이 우리가 scheduleUpdateOnFiber()에 신경 써야 할 유일한 줄입니다. 
      이 함수는 보류 중인 업데이트가 있는 경우 다시 렌더링이 예약되도록 합니다. 
      실제 재렌더링이 아직 시작되지 않았으므로 업데이트는 아직 처리되지 않습니다. 
      실제 재렌더링의 시작은 이벤트 카테고리 및 스케줄러 상태와 같은 몇 가지 요소에 따라 달라집니다. 
      이 함수에 대해 자세히 알아보려면 내부적으로 useTransition()이 어떻게 작동하는지를 참조하세요.
    */

  //...
}

3. 리렌더링에서의 useState()

업데이트가 저장되면 이제 실제로 업데이트를 실행하고 상태 값을 업데이트할 차례입니다.

이는 실제로 리렌더의 useState() 함수에서 발생합니다.

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}
function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  /*
  	이렇게 하면 이전에 생성한 훅이 제공되므로 값을 가져올 수 있습니다.
  */

  const queue = hook.queue;
  /*
  	리렌더링이 시작된 후 useState()가 호출되면 
    모든 업데이트가 저장된 update queue가 파이버로 이동합니다.
  */

  if (queue === null) {
    throw new Error(
      'Should have a queue. This is likely a bug in React. Please file an issue.',
    );
  }
  queue.lastRenderedReducer = reducer;
  const current: Hook = (currentHook: any);
  // 기본 상태의 일부가 아닌 마지막 리베이스 업데이트입니다.
  let baseQueue = current.baseQueue;
  /*
  baseQueue에 대한 설명이 필요합니다. 
  가장 좋은 경우에는 업데이트가 처리될 때 그냥 버리면 됩니다. 
  그러나 우선순위가 다른 여러 업데이트가 있을 수 있으므로 
  나중에 처리하기 위해 일부 업데이트를 건너뛰어야 할 수 있으므로 baseQueue에 저장합니다. 
  또한 처리된 업데이트의 경우에도 최종 상태가 올바른지 확인하기 위해 업데이트가 
  baseQueue에 저장되면 다음 업데이트도 모두 거기에 있어야 합니다. 
  예를 들어 상태 값이 1이면 +1(낮음), *10(높음), -2(낮음) 3개의 업데이트가 있는데 
  *10이 우선순위가 높으므로 처리하고, 1 * 10 = 10 나중에 우선순위가 낮은 것을 처리할 때 
  *10을 큐에 넣지 않으면 1 + 1 - 2 = 0이 되지만 (1 + 1) * 10 - 2가 필요하게 되죠.
  */

  // 아직 처리되지 않은 마지막 보류 중인 업데이트입니다.
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    // 아직 처리되지 않은 새로운 업데이트가 있습니다.
    // base queue에 추가하겠습니다.
    if (baseQueue !== null) {
      // pending queue와 base queue를 병합합니다.
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
    /*
      pending queue가 지워지고 baseQueue로 병합됩니다.
    */

  }
  if (baseQueue !== null) {
    // We have a queue to process.
    const first = baseQueue.next;
    let newState = current.baseState;
    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast = null;
    /*
      baseQueue를 처리한 후, 새로운 baseQueue가 생성됩니다.
    */

    let update = first;
    do {
    /*
      이 do...while 루프는 모든 업데이트를 처리하려고 시도합니다.
    */

      ...
      // 트리가 숨겨져 있을 때 이 업데이트가 이루어졌는지 확인합니다. 
      // 그렇다면 “기본” 업데이트가 아니므로 Offscreen tree에 진입했을 때 
      // renderLanes에 추가된 extra base lanes은 무시해야 합니다.
      const shouldSkipUpdate = isHiddenUpdate
        ? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
        : !isSubsetOfLanes(renderLanes, updateLane);
      if (shouldSkipUpdate) {
        // 우선 순위가 충분하지 않습니다.(우선 순위가 낮습니다.) 
        // 이 업데이트를 건너뛰세요. 
        // 처음 건너뛴 업데이트인 경우 이전 업데이트/상태가 새 기준이 됩니다.
        // 업데이트/상태가 됩니다.

        const clone: Update<S, A> = {
          lane: updateLane,
          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;
        }
        /*
          업데이트는 처리되지 않았기 때문에 새 baseQueue에 저장됩니다.
        */

        // 대기열의 남은 우선순위를 업데이트합니다.
        // TODO: 이를 누적할 필요는 없습니다. 대신 원래 레인에서 렌더레인을 제거하면 됩니다.
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
        markSkippedUpdateLanes(updateLane);
      } else {
        // This update does have sufficient priority.
        if (newBaseQueueLast !== null) {
      
          const clone: Update<S, A> = {
            // 이 업데이트는 커밋될 예정이므로 절대 커밋 취소하지 마세요. 
            // 0은 모든 비트마스크의 하위 집합이므로 위의 검사에서 절대로 건너뛰지 않으므로
            // NoLane을 사용하면 작동합니다.
            lane: NoLane,
            action: update.action,
            hasEagerState: update.hasEagerState,
            eagerState: update.eagerState,
            next: (null: any),
          };
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        /*
          a| 앞에서 baseQueue에 대해 설명했듯이, 
          여기에서는 newBaseQueue가 비어 있지 않으면 나중에 사용할 수 있도록 
          다음 업데이트를 모두 저장해야 한다고 말합니다.
        */

        // 이 업데이트를 처리합니다.
        if (update.hasEagerState) {
          // 이 업데이트가 상태 업데이트(리듀서가 아닌)이고 열심히 처리된 경우, 
          // 열심히 계산된 상태를 사용할 수 있습니다.
          newState = ((update.eagerState: any): S);
        } else {
          const action = update.action;
          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();
      /*
        다시 렌더링하는 동안 상태가 변경되지 않으면 실제로 bailout(조기 bailout되지 않음)됩니다.
      */

    }
    hook.memoizedState = newState;
    /*
      마지막으로 새 상태가 설정됩니다.
    */

    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;
    /*
      다음 라운드의 재렌더링을 위해 새로운 baseQueue가 설정됩니다.
    */

    queue.lastRenderedState = newState;
  }
  if (baseQueue === null) {
    // queue.lanes`는 전환을 얽히는 데 사용됩니다. 대기열이 비워지면 다시 0으로 설정할 수 있습니다.
    queue.lanes = NoLanes;
  }
  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
  /*
    이제 새로운 상태가 생겼습니다! 그리고 dispatch()가 안정적입니다!
  */

}

 

4. 요약

https://jser.dev/2023-06-19-how-does-usestate-work/#4-summary 참고

5. caveats 이해하기

React.dev에 주의 사항(caveats)이 나열되어 있으니, 왜 주의 사항이 존재하는지 이해해 봅시다.

 

5.1  상태 업데이트가 동기화되지 않습니다.

set 함수는 다음 렌더링에 대한 상태 변수만 업데이트합니다. set 함수를 호출한 후 상태 변수를 읽으면 호출 전 화면에 있던 이전 값을 계속 가져옵니다.

이는 이해하기 쉬운데, 이미 setState()가 다음 틱에서 다시 렌더링을 예약하고 동기화가 아니며 상태 업데이트가 setState()가 아닌 useState()에서 수행되므로 업데이트된 값은 다음 렌더링에서만 가져올 수 있다는 것을 보았습니다.

 

5.2 같은 값으로 setState()를 호출하면 여전히 렌더링이 다시 트리거될 수 있습니다.

 

Object.is 비교에 의해 결정된 대로 사용자가 제공하는 새 값이 현재 상태와 동일한 경우, React는 컴포넌트와 그 자식들을 다시 렌더링하지 않고 건너뜁니다. 이것은 최적화입니다. 경우에 따라 React가 자식들을 건너뛰기 전에 컴포넌트를 호출해야 할 수도 있지만 코드에는 영향을 미치지 않습니다.

이것이 가장 까다로운 주의 사항입니다. 다음은 한 번 해볼 수 있는 퀴즈입니다.

function A() {
  console.log(2);
  return null;
}
function App() {
  const [_state, setState] = useState(false);
  console.log(1);
  return (
    <>
      <button
        onClick={() => {
          console.log("click");
          setState(true);
        }}
        data-testid="action"
      >
        click me
      </button>
      <A />
    </>
  );
}
ReactDOM.render(<App />, document.getElementById("root"));
const action = document.querySelector('[data-testid="action"]');
fireEvent.click(action);
fireEvent.click(action);
fireEvent.click(action);

정답: 1 2 click 1 2 click 1 click

 

같은 상태 값을 설정해도 왜 다시 렌더링이 발생하는지 알아내는 데 꽤 오랜 시간이 걸렸습니다.

이를 이해하려면 더 깊이 파고들지 않은 dispatchSetState() 내부의 간절한 bailout 조건으로 돌아가야 합니다.

 

if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
      /*
      이러한 조건에서 상태가 변경되지 않으면 다시 렌더링 예약을 피하십시오.
      */

    ) {

이전 슬라이드에서 설명한 것처럼, 가장 좋은 확인 방법은 보류 중인 update queue과 hook에 대한 baseQueue가 비어 있는지 확인하는 것입니다. 하지만 현재 구현에서는 실제로 다시 렌더링을 시작할 때까지는 사실 여부를 알 수 없습니다.

따라서 여기서는 파이버 노드에 업데이트가 없는지 확인하는 간단한 방법으로 되돌아갑니다. 업데이트가 queue에 추가되면 파이버가 더티로 표시되므로 리렌더링이 시작될 때까지 기다릴 필요가 없습니다.

하지만 부작용이 있습니다.

function enqueueUpdate(
  fiber: Fiber,
  queue: ConcurrentQueue | null,
  update: ConcurrentUpdate | null,
  lane: Lane,
) {
  concurrentQueues[concurrentQueuesIndex++] = fiber;
  concurrentQueues[concurrentQueuesIndex++] = queue;
  concurrentQueues[concurrentQueuesIndex++] = update;
  concurrentQueues[concurrentQueuesIndex++] = lane;
  concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
  fiber.lanes = mergeLanes(fiber.lanes, lane);
  const alternate = fiber.alternate;
  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane);
  }
}

업데이트를 대기열에 넣을 때 현재 및 대체 파이버가 모두 레인으로 더티로 표시되어 있는 것을 볼 수 있습니다. 이는 필요한데, dispatchSetState()가 소스 파이버에 바인딩되어 있으므로 현재 및 대체를 모두 업데이트하지 않으면 업데이트가 처리되는지 확인할 수 없기 때문입니다.

 

그러나 레인 지우기는 실제 리렌더링이 이루어지는 beginWork()에서만 이루어집니다.

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  ...
  // 시작 단계로 들어가기 전에 보류 중인 업데이트 우선순위를 지웁니다.
  // TODO: 여기서는 컴포넌트를 평가하고 업데이트 대기열을 처리할 예정이라고 가정합니다.
  // 하지만 예외가 있습니다: SimpleMemoComponent는 때때로 시작 단계의 후반부에 중단됩니다. 
  // 이는 이 할당을 공통 경로에서 벗어나 각 브랜치로 이동해야 함을 나타냅니다.
  workInProgress.lanes = NoLanes;
  ...
}

따라서 업데이트가 예약되면 더티 레인 플래그가 완전히 지워지는 것은 최소 2번의 재렌더링을 거친 후에야 이루어집니다.

단계는 대략 다음과 같습니다.

 

fiber1(current, clean) / null(alternate) → fiber1은 useState()의 소스 파이버입니다.
setState(true) → 참은 거짓과 다르기 때문에 조기 bailout이 발생하지 않습니다.
fiber1(current, dirty) / null(alter) → 업데이트를 큐에 넣습니다.
fiber1 (current, dirty) / fiber2 (workInProgress, dirty) → 재 렌더링 시작, workInProgress로 새 파이버 생성
fiber1( current, dirty ) / fiber2(workInProgress, clean) → beginWork()에서 레인이 지워짐
fiber1 (alternate, dirty) / fiber2 ( current, clean ) → 커밋 후, React는 2 버전의 파이버 트리를 교환합니다.
setState(true) → 파이버 중 하나가 깨끗하지 않기 때문에 조기 구제금융이 여전히 일어나지 않습니다.
fiber1 ( alternate, dirty ) / fiber2 ( current, dirty ) → 업데이트를 큐에 넣습니다.
fiber1(workInProgress, dirty) / fiber2( current, dirty ) → 재렌더링 시작, fiber1이 fiber2의 레인을 할당받음.
fiber1 (workInProgress, clean) / fiber2 (current, dirty) → beginWork()에서 레인이 지워짐
fiber1 (workInProgress, clean) / fiber2 (current, clean) → 상태 변경이 발견되지 않음, bailoutHooks()에서 현재 파이버에 대한 레인이 제거되고, bailout(조기 구제되지 않음)이 발생함.
fiber1 (current, clean) / fiber2 (alternate, clean) → 커밋 후, React는 2 버전의 파이버 트리를 교체합니다.
setState(true) → 이번에는 두 파이버가 모두 깨끗해져서 실제로 조기 베일아웃을 할 수 있습니다!

 

이 문제를 해결할 수 있는 방법이 있을까요? 하지만 파이버 아키텍처와 후크 작동 방식 때문에 비용이 너무 많이 들 수도 있습니다. 대부분의 경우 문제가 되지 않기 때문에 React 팀에서 수정할 의사가 없는 것으로 보입니다.

React는 필요하다고 생각되면 렌더링을 다시 하지만, 성능 트릭이 항상 작동한다고 가정해서는 안 된다는 점을 명심해야 합니다.

 

5.3 React 배치 상태 업데이트

React 배치 상태 업데이트. 모든 이벤트 핸들러가 실행되고 설정된 함수를 호출한 후에 화면을 업데이트합니다. 이렇게 하면 단일 이벤트 중에 여러 번 다시 렌더링되는 것을 방지할 수 있습니다. 드물지만 DOM에 액세스하기 위해 React가 화면을 더 일찍 업데이트해야 하는 경우, flushSync를 사용할 수 있습니다.

이전 슬라이드에서 설명한 것처럼 업데이트는 실제로 처리되기 전에 저장된 후 함께 처리됩니다.