영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크: https://jser.dev/2023-06-19-how-does-usestate-work
ℹ️ React Internals Deep Dive 에피소드 5, 유튜브에서 제가 설명하는 것을 시청해주세요.
⚠ React@18.2.0 기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.
💬 역자 주석: Jser의 코멘트는 ❗❗로 표시 해뒀습니다.
그 외 주석은 리액트 소스 코드 자체의 주석입니다.
... 은 생략된 코드입니다.
나는 당신이 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>
);
}
- [코드 샌드박스 링크](https://codesandbox.io/p/sandbox/priceless-rubin-7hs2gc)
1. 최초 렌더 혹은 마운트 시 useState()
초기 렌더링은 매우 간단합니다.
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
// ❗❗ ↗ 새로운 훅이 생성됩니다.
if (typeof initialState === 'function') {
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
// ❗❗ ↖ hook에 있는 memoizedState는 실제 state의 값을 보유 합니다.
const queue: UpdateQueue<S, BasicStateAction<S>> = {
// ❗❗ Updatequeue는 향후 상태 업데이트를 보관하는 곳입니다.
// ❗❗ state를 설정할 때 state 값이 바로 업데이트 되는게 아니라는 것을 명심 해야 합니다.
// ❗❗ 왜냐하면 업데이트들은 서로 다른 우선순위를 갖고 있고, 꼭 바로 처리되야 하는게 아닙니다.
// ❗❗ 더 자세한 정보는 https://jser.dev/react/2022/03/26/lanes-in-react/ 를 참고 해주세요.
// ❗❗ 그러므로 우리는 업데이트를 저장한 다음, 나중에 처리해야 합니다.
pending: null,
lanes: NoLanes,
// ❗❗ ↖ lanes 가 우선순위입니다.
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
};
hook.queue = queue;
// ❗❗ ↖ 이 queue는 훅을 위한 업데이트 큐라는 것을 명심하세요.
const dispatch: Dispatch<
BasicStateAction<S>,
> = (queue.dispatch = (dispatchSetState.bind(
// ❗❗ ↗ state 설정자(setter)에게 실제로는 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);
// ❗❗ ↗ 이게 업데이트의 우선순위를 정의합니다.
// ❗❗ 더 자세한 정보는 https://jser.dev/react/2022/03/26/lanes-in-react/ 참고 하세요.
const update: Update<S, A> = {
lane,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
// ❗❗ ↖ 여기에 저장할 예정인 업데이트 객체가 있습니다.
if (isRenderPhaseUpdate(fiber)) {
// ❗❗ 렌더링 중에 setState를 할 수 있습니다. 이것도 유용한 패턴입니다. https://react.dev/reference/react/useState#storing-information-from-previous-renders
// ❗❗ 다만 무한 렌더링으로 이어질 수 있으니 조심해야 합니다.
enqueueRenderPhaseUpdate(queue, update);
} else {
const alternate = fiber.alternate;
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
// ❗❗ ↖ 이 상태 확인은 빠른 bailout을 위한 것입니다,
// ❗❗ 만약 동일한 state가 설정되있으면 아무것도 하지 않습니다.
// ❗❗ Bailout은 하위 트리의 리-렌더링을 건너뛰기 위해 더 깊이 내려가지 않는 것을 의미하며,
// ❗❗ 여기서는 리-렌더링 내부에 있으므로, 리-렌더링 예약을 피하기 위한 이른 bailout 입니다.
// ❗❗ 하지만 여기 있는 조건은 실제로는 핵(Hack)이며,
// ❗❗ 필요 이상으로 엄격한 규칙으로
// ❗❗ React는 최선을 다해 리-렌더링 예약을 피하려고 하지만 보장할 수는 없습니다.
// ❗❗ 우리는 이 내용에 대한 자세한 내용을 하단의 주의(caveat) 섹션에서 다룰 것입니다.
) {
// 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;
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;
// ❗❗ ↖ 이 반환은 업데이트가 예약되지 않도록 예방합니다.
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
if (__DEV__) {
ReactCurrentDispatcher.current = prevDispatcher;
}
}
}
}
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
// ❗❗ ↗ 여기서 업데이트들을 보관 합니다.
// ❗❗ 업데이트들은 실제 리-렌더링이 시작될 때 처리되어 Fiber에 첨부(attach)됩니다.
if (root !== null) {
const eventTime = requestEventTime();
scheduleUpdateOnFiber(root, fiber, lane, eventTime);
// ❗❗ ↖ 여기가 리-렌더링을 예약합니다. 리-렌더링이 즉시 수행되지 않음을 알 수 있습니다.
// ❗❗ 실제 예약은 리액트 Scheduler에 의해 정해집니다. 참고링크 https://jser.dev/react/2022/03/16/how-react-scheduler-works/
entangleTransitionUpdate(root, queue, lane);
}
}
}
업데이트 객체가 어떻게 처리되는지 자세히 살펴보겠습니다.
// If a render is in progress, and we receive an update from a concurrent event,
// we wait until the current render is over (either finished or interrupted)
// before adding it to the fiber/hook queue. Push to this array so we can
// access the queue, fiber, update, et al later.
const concurrentQueues: Array<any> = [];
let concurrentQueuesIndex = 0;
let concurrentlyUpdatedLanes: Lanes = NoLanes;
export function finishQueueingConcurrentUpdates(): void {
// ❗❗ ↗ 이 함수는 prepareFreshStack() 함수 안에서 호출됩니다,
// ❗❗ 즉 이것은 리-렌더링의 최초 단계들 중 하나 입니다.
// ❗❗ 이것은 실제 리-렌더링이 시작되기 전에 모든 state 업데이트가 저장된다는 것을 말합니다.
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 기억하나요?
// ❗❗ 여기서 우리는 보관된 업데이트들이 드디어 fiber에 첨부(attach) 되는 것을 볼 수 있고,
// ❗❗ 이것은 처리할 준비를 한다는 의미입니다.
}
if (lane !== NoLane) {
markUpdateLaneFromFiberToRoot(fiber, update, lane);
// ❗❗ 또한 파이버 노드 경로를 dirty로 표시하는 이 함수 호출에 주목하세요
// ❗❗ 좀 더 자세히는 링크를 참조해 주세요. https://jser.dev/react/2022/01/07/how-does-bailout-work
}
}
}
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;
// ❗❗ 내부적으로 업데이트들은 리스트에 유지되고 있습니다.
// ❗❗ 마치 배치에서 처리될 메세지 큐처럼요.
concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
// The fiber's `lane` field is used in some places to check if any work is
// scheduled, to perform an eager bailout, so we need to update it immediately.
// TODO: We should probably move this to the "shared" queue instead.
fiber.lanes = mergeLanes(fiber.lanes, lane);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
// ❗❗ current와 alternate Fiber들이 다 dirty라고 표시되있는 것을 볼 수 있습니다.
// ❗❗ 이것은 우리가 하단의 주의사항(caveat)을 이해하는데 중요합니다.
}
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); // ❗❗ enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane)
return getRootForUpdatedFiber(fiber);
}
💬 역자 주석: dirty 플래그란, 변경 사항이 있는 파이버를 가리키는 플래그입니다.
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);
}
// ❗❗ current 파이버와 alternate 파이버 둘 다의 lanes들이 업데이트됩니다.
// ❗❗ dispatchSetState()가 source 파이버에 바인딩되어 있다고 언급했음을 기억하세요.
// ❗❗ 그래서 상태를 설정할 때 current 파이버 트리가 항상 업데이트되지 않을 수 있습니다.
// ❗❗ 두 가지를 모두 설정하면 모든 것이 제대로 작동하지만 부작용이 있습니다.
// ❗❗ 주의(caveat) 섹션에서 다시 다루겠습니다.
// 상위 경로를 루트로 이동시키고 하위 경로를 업데이트합니다. ❗❗ <-
// ❗❗ ↖ 더 자세한 정보는 링크를 참조 해주세요. https://jser.dev/react/2022/01/07/how-does-bailout-work/
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,
) {
checkForNestedUpdates();
// Mark that the root has a pending update.
markRootUpdated(root, lane, eventTime);
if (
(executionContext & RenderContext) !== NoLanes &&
root === workInProgressRoot
) {
// Track lanes that were updated during the render phase
workInProgressRootRenderPhaseUpdatedLanes = mergeLanes(
workInProgressRootRenderPhaseUpdatedLanes,
lane,
);
} else {
if (root === workInProgressRoot) {
// Received an update to a tree that's in the middle of rendering. Mark
// that there was an interleaved update work on this root. Unless the
// `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render
// phase update. In that case, we don't treat render phase updates as if
// they were interleaved, for backwards compat reasons.
if (
deferRenderPhaseUpdateToNextBatch ||
(executionContext & RenderContext) === NoContext
) {
workInProgressRootInterleavedUpdatedLanes = mergeLanes(
workInProgressRootInterleavedUpdatedLanes,
lane,
);
}
if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
// The root already suspended with a delay, which means this render
// definitely won't finish. Since we have a new update, let's mark it as
// suspended now, right before marking the incoming update. This has the
// effect of interrupting the current render and switching to the update.
// TODO: Make sure this doesn't override pings that happen while we've
// already started rendering.
markRootSuspended(root, workInProgressRootRenderLanes);
}
}
ensureRootIsScheduled(root, eventTime);
// ❗❗ ↖ 이 줄이 scheduleUpdateOnFiber()에 신경 써야 하는 유일한 줄입니다.
// ❗❗ 이 함수는 보류 중인 업데이트가 있는 경우 리-렌더링이 예약되도록 합니다.
// ❗❗ 실제 리-렌더링이 아직 시작되지 않았기 때문에 업데이트가 아직 처리되지 않았습니다.
// ❗❗ 실제 리-렌더링의 시작은 이벤트 카테고리, 스케줄러 상태 등 몇 가지 요인에 따라 달라집니다.
// ❗❗ 우리는 이 기능을 여러번 만났습니다.
// ❗❗ 이 함수에 대해 자세히 알아보려면 내부적으로 어떻게 useTransition()이 작동하는지를 참조하세요.
// ❗❗ 참고 링크: https://jser.dev/2023-05-19-how-does-usetransition-work/#31-use-case-1---marking-a-state-update-as-a-non-blocking-transition
if (
lane === SyncLane &&
executionContext === NoContext &&
(fiber.mode & ConcurrentMode) === NoMode &&
// Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
!(__DEV__ && ReactCurrentActQueue.isBatchingLegacy)
) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
resetRenderTimer();
flushSyncCallbacksOnlyInLegacyMode();
}
}
}
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()가 호출되므로, 저장된 업데이트가 파이버로 이동합니다.
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);
// The last rebase update that is NOT part of the base state.
let baseQueue = current.baseQueue;
// ❗❗ ↗ baseQueue에 대한 설명이 필요합니다.
// ❗❗ 가장 좋은 경우, 우리는 업데이트가 처리될 때 그냥 버리면 됩니다.
// ❗❗ 그러나 우선순위가 다른 여러 업데이트가 있을 수 있으므로
// ❗❗ 나중에 처리하기 위해 일부를 건너뛰어야 할 수도 있습니다.
// ❗❗ 이것이 바로 baseQueue에 저장되는 이유입니다.
// ❗❗ 또한 처리된 업데이트의 경우에도 최종 state가 올바른지 확인하려면
// ❗❗ 업데이트가 baseQueue에 저장되면 다음 업데이트도 모두 거기에 있어야 합니다.
// ❗❗ 예를 들어, state 값이 1이고, 업데이트가 3개 있을때, +1(low), *10(high), -2(low)
// ❗❗ *10 이 우선순위가 높으므로, 처리하면, 1 * 10 = 10
// ❗❗ 그 후 낮은 우선순위를 가진 것들을 처리합니다,
// ❗❗ 만약 우리가 *10을 큐에 넣지 않으면, 1 + 1 - 2 = 0이 됩니다.
// ❗❗ 하지만 우리에게 필요한 것은 (1 + 1) * 10 - 2 입니다.
// 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) {
// 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;
// ❗❗ ↖ 보류 중인 큐가 정리되고 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 루프는 모든 업데이트를 처리하려고 시도합니다.
...
// Check if this update was made while the tree was hidden. If so, then
// it's not a "base" update and we should disregard the extra base lanes
// that were added to renderLanes when we entered the Offscreen tree.
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에 저장됩니다.
// Update the remaining priority in the queue.
// TODO: Don't need to accumulate this. Instead, we can remove
// renderLanes from the original lanes.
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
updateLane,
);
markSkippedUpdateLanes(updateLane);
} else {
// This update does have sufficient priority.
if (newBaseQueueLast !== null) {
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,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// ❗❗ ↖ 앞서 baseQueue에 대해 설명했듯이, 여기서는 newBaseQueue가 비어 있지 않으면,
// ❗❗ 나중에 사용할 수 있도록 다음의 모든 업데이트를 저장해야 합니다.
// Process this update.
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 {
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);
}
// Mark that the fiber performed work, but only if the new state is
// different from the current state.
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
// ❗❗ ↖ 리-렌더링하는 동안 state가 변경되지 않으면
// ❗❗ 실제로 bailout(이른 bailout 이 아니라)됩니다.
}
hook.memoizedState = newState;
// 마침내 새로운 state가 설정됩니다.
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast;
// ↖ 다음 라운드의 리-렌더링을 위해 새로운 baseQueue가 설정됩니다.
queue.lastRenderedState = newState;
}
if (baseQueue === null) {
// `queue.lanes` is used for entangling transitions. We can set it back to
// zero once the queue is empty.
queue.lanes = NoLanes;
}
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
// ❗❗ ↖ 이제 새로운 state가 생겼습니다! 그리고 dispatch()가 안정적입니다!
}
4. 요약
다음은 내부를 설명하는 몇 가지 간단한 슬라이드입니다.
- 슬라이드 링크: 링크
위 슬라이드의 경우 실제 데모를 사용해 볼 수 있습니다.
5. 주의사항(caveats) 이해하기
React.dev에 주의 사항이 나열되어 있는데, 왜 그런 주의 사항이 존재하는지 이해해 보겠습니다.
5.1 state 업데이트는 동기화 되지 않습니다.
set 함수는 다음 렌더링에 대한 state 변수만 업데이트합니다. set 함수를 호출한 후 state 변수를 읽으면 호출 전 화면에 있던 이전 값을 계속 가져옵니다.
이는 이해하기 쉬운데, 이미 setState()
가 다음 틱에서 리-렌더링을 예약하고 동기화 동작도 아니며 state 업데이트가 setState()
가 아닌 useState()
에서 수행되므로 업데이트된 값은 다음 렌더링에서만 가져올 수 있다는 것을 보았습니다.
5.2 동일한 값을 가진 setState()
는 여전히 리-렌더링을 트리거할 수 있습니다.
Object.is 비교에 의해 결정된 대로 사용자가 제공하는 새 값이 현재 state와 동일한 경우, React는 컴포넌트와 그 자식들의 리-렌더링을 생략합니다. 이것은 최적화입니다. 경우에 따라 React가 자식들을 건너뛰기 전에 컴포넌트를 호출해야 할 수도 있지만 코드에는 영향을 미치지 않습니다.
이것이 가장 까다로운 주의 사항입니다. 다음은 한 번 해볼 수 있는 퀴즈입니다.
import React, { useState } from 'react'
import ReactDOM from 'react-dom'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
function A() {
console.log('render A')
return null
}
function App() {
const [_state, setState] = useState(false)
console.log('render App')
return <div>
<button onClick={() => {
console.log('click')
setState(true)
}}>click me</button>
<A />
</div>
}
ReactDOM.render(<App/>, document.getElementById('root'))
userEvent.click(screen.getByText('click me'))
userEvent.click(screen.getByText('click me'))
userEvent.click(screen.getByText('click me'))
// 최종 출력 결과를 한줄 한줄씩 예상해보세요
- 정답 링크: 링크
같은 state 값을 설정해도 왜 리-렌더링이 발생하는지 알아내는 데 꽤 오랜 시간이 걸렸습니다.
이를 이해하려면 더 깊이 파고들지 않은 dispatchSetState()
내부의 긴급 bailout 조건으로 돌아가야 합니다.
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
// ❗❗ 이러한 조건에서 state가 변경되지 않으면 리-렌더링 예약을 피하십시오.
) {
이전 슬라이드에서 설명한 것처럼, 가장 좋은 확인 방법은 보류 중인 업데이트 큐와 훅에 대한 baseQueue가 비어 있는지 확인하는 것입니다. 하지만 현재 구현 방식으로는 실제로 리-렌더링을 시작할 때까지는 사실 여부를 알 수 없습니다.
따라서 여기서는 파이버 노드에 업데이트가 없는지 간단히 확인하는 것으로 돌아갑니다. 업데이트가 큐에 추가되면 파이버가 dirty로 표시되므로 리-렌더링이 시작될 때까지 기다릴 필요가 없습니다.
하지만 부작용도 있습니다.
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);
}
}
업데이트를 큐에 넣을 때 current 및 alternate 파이버 모두 lanes가 dirty로 표시되어 있는 것을 볼 수 있습니다. 이는 dispatchSetState()
가 소스 파이버에 바인딩되어 있으므로 current와 alternate를 모두 업데이트하지 않으면 업데이트가 처리되는지 확인할 수 없기 때문에 필요합니다.
current와 alternate에 대한 건, 디버깅 비디오를 참고해주세요
하지만 lanes 지우기는 실제 리-렌더링이 이루어지는 beginWork()
에서만 이루어집니다.
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
...
// Before entering the begin phase, clear pending update priority.
// TODO: This assumes that we're about to evaluate the component and process
// the update queue. However, there's an exception: SimpleMemoComponent
// sometimes bails out later in the begin phase. This indicates that we should
// move this assignment out of the common path and into each branch.
workInProgress.lanes = NoLanes;
...
}
이로 인해 업데이트가 예약되면 최소 2라운드의 리-렌더링을 거쳐야만 dirty lanes 플래그가 완전히 지워지는 결과가 발생합니다.
단계는 대략 다음과 같습니다.
fiber1(current, clean) / null(alternate) → fiber1은
useState()
의 소스 파이버입니다.setState(true)
→true
는false
와 다르기 때문에 이른 bailout 조치가 발생하지 않습니다.fiber1(current, dirty) / null(대체) → 업데이트 큐에 삽입
fiber1(current, dirty) / fiber2(workInProgress, dirty) → 리-렌더링 시작, 새 파이버를 workInProgress로 생성함
fiber1(current, dirty) / fiber2(workInProgress, clean) →
beginWork()
에서 lanes가 지워집니다.fiber1(alternate, dirty) / fiber2(current, clean) → 커밋 후 React는 2가지 버전의 파이버 트리를 바꿉(swap)니다.
setState(true)
→ 파이버 중 하나가 깨끗하지 않기 때문에 이른 bailout이 여전히 발생하지 않습니다.fiber1(alternate, dirty)/fiber2(current, dirty) → 업데이트 큐에 삽입
fiber1(workInProgress, dirty) / fiber2(current, dirty) → 리-렌더링 시작, fiber1에 fiber2의 lanes 할당
fiber1 (workInProgress, clean) / fiber2 (current, dirty) →
beginWork()
에서 lanes가 지워집니다.fiber1(workInProgress, clean) / fiber2(current, clean) → state 변경이 발견되지 않고,
bailoutHooks()
에서 현재 파이버에 대한 lanes가 제거되고, bailout(이른 bailout 아님)가 발생합니다.fiber1(current, clean) / fiber2(alternate, clean) → 커밋 후 React는 2가지 버전의 파이버 트리를 교체합니다.
setState(true)
→ 이번에는 두 파이버가 모두 깨끗해져서 실제로 이른 bailout을 할 수 있습니다!
이 문제를 해결할 수 있을까요? 하지만 파이버 아키텍처와 훅의 작동 방식 때문에 비용이 너무 많이 들었을 수도 있습니다. 대부분의 경우 문제가 되지 않기 때문에 React 팀에서 수정할 의사가 없는 것으로 보이는 논의가 이미 있었습니다.
React는 필요할 경우 리-렌더링을 수행한다는 점을 명심해야 하며,성능 트릭이 항상 작동한다고 가정해서는 안 됩니다.
5.3 React state 업데이트 배치 처리
React는 state 업데이트를 일괄 처리합니다. 모든 이벤트 핸들러가 실행되고 설정된 함수를 호출한 후에 화면을 업데이트합니다. 이렇게 하면 단일 이벤트 중에 여러 번 리-렌더링하는 것을 방지할 수 있습니다. 드물지만 DOM에 액세스하는 등 React가 화면을 더 일찍 업데이트하도록 강제해야 하는 경우 flushSync를 사용할 수 있습니다.
이전 슬라이드에서 설명한 것처럼 업데이트는 실제로 처리되기 전에 저장된 후 함께 처리됩니다.
(원본 게시일: 2023-07-23)