JSer.dev의 React Internals Deep Dive를 번역하여 정리한 글입니다.
⚠️ React@19의 commit 7608516을 기반으로 작성되었으며, 최신 버전에서는 구현이 변경되었을 수 있습니다.
useState
는 컴포넌트에 state variable을 추가할 수 있게 하는 React Hook이다. 소스 코드를 살펴보며 useState()
의 동작 원리를 알아보자.
const [state, setState] = useState(initialState)
useState()
in initial render(mount)💻 src: ReactFiberHooks.js
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
// 📌 새로운 hook이 생성된다.
const hook = mountStateImpl(initialState);
// 📌 이 queue는 hook을 위한 update queue임을 기억하자.
const queue = hook.queue;
// 📌 state setter의 구현체가 바로 dispatchSetState()다.
// current fiber에 바인딩되어있음을 확인하자.
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
null,
currentlyRenderingFiber,
queue,
): any);
queue.dispatch = dispatch;
// 📌 다음은 useState()로부터 얻을 수 있는 익숙한 syntax다.
return [hook.memoizedState, dispatch];
}
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
const initialStateInitializer = initialState;
initialState = initialStateInitializer();
}
// 📌 hook의 memoizedState는 실제 state 값을 저장한다.
hook.memoizedState = hook.baseState = initialState;
// 📌 Update queue는 미래의 state 업데이트를 저장할 곳이다.
// state를 설정(set)할 때, state 값이 바로 업데이트되지 않는 점을 기억하자.
// 왜냐하면, 업데이트가 다른 우선순위를 가질 수 있기 때문에
// state 업데이트가 바로 처리될 필요가 없을 수 있다. (Cf. [What are Lanes in React](https://jser.dev/react/2022/03/26/lanes-in-react/))
// 그러므로 업데이트를 stash해뒀다가, 나중에 처리해야 한다.
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
lanes: NoLanes, // 📌 lanes는 우선순위다.
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
};
hook.queue = queue;
return hook;
}
setState()
?상단의 코드를 보면, setState()
는 바인딩된 dispatchsetState()
다.
💻 src: ReactFiberHooks.js
function dispatchSetState<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
): void {
// 📌 lane은 업데이트의 우선순위를 정의한다.
const lane = requestUpdateLane(fiber);
// 📌 stash될 업데이트 객체
const update: Update<S, A> = {
lane,
revertLane: NoLane,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
// 📌 render 중에 setState를 할 수 있다.
// Cf. https://react.dev/reference/react/useState#storing-information-from-previous-renders
// 이 패턴은 유용하지만, 무한 렌더링을 일으킬 수 있으므로 조심하자.
if (isRenderPhaseUpdate(fiber)) {
enqueueRenderPhaseUpdate(queue, update);
} else {
const alternate = fiber.alternate;
// 📌 early bailout을 위한 조건문이다.
// 이는 같은 state를 set하고 있다면 아무것도 하지 않는 것을 의미한다.
// - Bailout은 더 이상 깊이 들어가지 않고, subtree의 리렌더링을 건너뛰는 것을 의미하며, 리렌더링 내부에서 발생한다.
// - 반면에 early bailout은 리렌더링 스케줄링을 방지한다.
// 하지만 이 조건문에는 더 엄격한 규칙이 필요한데, 이 조건문으로는
// React가 리렌더링 스케줄링을 방지하려고 최대한 노력하는 것뿐이지, 보장하지는 않기 때문이다.
// caveat section에서 더 자세히 살펴보자.
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;
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.
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
// 📌 이 리턴은 업데이트가 스케줄링되는 것을 방지한다.
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
}
}
}
// 📌 enqueueConcurrentHookUpdate는 업데이트들을 stash한다.
// 업데이틀은 실제 리렌더링이 시작될 때, 처리되어 fiber에 attach된다.
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
// 📌 scheduleUpdateOnFiber는 리렌더링을 예약한다. (리렌더링은 즉시 일어나지 않는다)
// 실제 스케줄링은 React Scheduler에서 처리한다.
// Cf. https://jser.dev/react/2022/03/16/how-react-scheduler-works/
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
}
}
}
💡 Early bailout vs Bailout
- early bailout
같은 state를 set하고 있다면 아무것도 하지 않는 것을 의미한다.
⇒ 리렌더링 스케줄링을 방지한다.- bailout
더 이상 깊이 들어가지 않고 subtree의 리렌더링을 건너뛰는 것을 의미하며, 리렌더링 내부에서 발생한다.
업데이트 객체가 어떻게 처리되는지 더 자세히 살펴보자.
💻 src: ReactFiberConcurrentUpdates.js
// 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;
// 📌 이 함수는 prepareFreshStack()에서 호출되며, re-render의 첫 번째 과정 중 하나다.
// Cf. https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js
// 리렌더링이 시작되기 전에, 모든 state 업데이트들이 stash 되어있음을 의미한다.
export function finishQueueingConcurrentUpdates(): void {
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;
}
// 📌 hook.queue에 stash 되었던 업데이트들이
// 드디어 여기에서 fiber에 attach된다.
// 업데이트가 처리될 준비가 되었음을 의미한다.
queue.pending = update;
}
if (lane !== NoLane) {
// 📌 이 함수 호출은 fiber node 경로를 dirty로 표시한다.
// Cf. https://jser.dev/react/2022/01/07/how-does-bailout-work/
markUpdateLaneFromFiberToRoot(fiber, update, lane);
}
}
}
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.
// 📌 내부적으로 업데이트들은 리스트에 저장된다.
// batch로 처리되는 message queue처럼 말이다.
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.
fiber.lanes = mergeLanes(fiber.lanes, lane);
// 📌 current와 alternate fibers 모두 dirty 표시되는 것을 볼 수 있다.
// 이것은 caveat을 이해하기 위해 중요하다.
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
}
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;
// 📌 current fiber와 alternate fiber 모두 lanes가 업데이트된다.
// dispatchSetState()가 source fiber에 바인딩되어있으므로,
// state를 set할 때, 항상 current fiber 트리를 업데이트하지는 않을 수 있다.
// 둘 다 set하면 정상 작동하게끔 보장은 할 수 있지만, 부작용이 있다.
// caveat section에서 이 부분을 다시 다루자.
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
// Walk the parent path to the root and update the child lanes.
// 📌 Cf. 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 | null = parent.stateNode;
if (
offscreenInstance !== null &&
!(offscreenInstance._visibility & OffscreenVisible)
) {
isHidden = true;
}
}
node = parent;
parent = parent.return;
}
if (isHidden && update !== null && node.tag === HostRoot) {
const root: FiberRoot = node.stateNode;
markHiddenUpdate(root, update, lane);
}
}
💻 src: ReactFiberWorkLoop.js
export function scheduleUpdateOnFiber(
root: FiberRoot,
fiber: Fiber,
lane: Lane,
) {
...
// 📌 pending 업데이트가 있는 경우, re-render가 예약되었는지 확인한다.
// 리렌더링이 아직 시작되지 않았으므로, 업데이트는 아직 처리되지 않았다.
// 리렌더링의 시작은 이벤트의 종류와 스케줄러의 상태와 같은 몇 가지 요소에 따라 달라진다.
// 더 자세한 내용은 다음 글에서 확인하자. https://jser.dev/2023-05-19-how-does-usetransition-work/#31-use-case-1---marking-a-state-update-as-a-non-blocking-transition
ensureRootIsScheduled(root);
...
}
업데이트가 stash 되었다면, 업데이트를 실행시켜서 state 값을 업데이트해야 한다.
이는 리렌더링의 useState()
에서 일어난다.
💻 src: ReactFiberHooks.js
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, initialState);
}
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
// 📌 이전에 생성되었던 hook으로부터 값을 얻는다.
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>] {
// 📌 모든 업데이트가 저장되어 있는 update queue다.
// useState()가 리렌더링이 시작된 후에 호출되었으므로, stash 된 업데이트는 fibers로 이동했다.
const queue = hook.queue;
...
queue.lastRenderedReducer = reducer;
// The last rebase update that is NOT part of the base state.
// 📌 가장 좋은 것은, 업데이트를 처리하고 바로 버리는 것이다.
// 하지만 여러 업데이트가 서로 다른 우선순위를 가질 수 있으므로,
// 어떤 업데이트는 나중에 처리되도록 건너뛰어야 하기 때문에, 업데이트들이 baseQueue에 저장된다.
// 처리된 업데이트더라도, 최종 state가 올바른지 확인하기 위해서
// 한 업데이트가 baseQueue에 들어가면, 그 뒤에 있는 모든 업데이트들도 함께 저장된다.
// e.g. state 값이 1이고, 세 번의 업데이트가 있다고 하자. +1(low), *10(high), -2(low)
// *10이 높은 우선순위를 갖기 때문에, 먼저 처리한다; 1 * 10 = 10
// 그 다음에 낮은 우선순위를 가진 업데이트들을 처리한다.
// *10을 큐에 넣지 않았다면, 1 + 1 - 2 = 0이 되겠지만,
// 낮은 우선순위의 +1부터 모두 큐에 들어가므로, (1 + 1) * 10 - 2로 계산된다.
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) {
// 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;
// 📌 pending queue가 지워지고, baseQueue에 merge된다.
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.
hook.memoizedState = baseState;
} else {
// We have a queue to process.
const first = baseQueue.next;
let newState = baseState;
let newBaseState = null;
// 📌 baseQueue를 처리한 이후에, 새로운 baseQueue가 생성된다.
let newBaseQueueFirst = null;
let newBaseQueueLast: Update<S, A> | null = null;
let update = first;
let didReadFromEntangledAsyncAction = false;
do {
...
// 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) {
// 📌 우선순위가 낮은 업데이트
// 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),
};
// 📌 업데이트가 처리되지 않았으므로, 새로운 baseQueue에 들어간다.
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// Update the remaining priority in the queue.
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
updateLane,
);
markSkippedUpdateLanes(updateLane);
} else {
// This update does have sufficient priority.
// Check if this is an optimistic update.
const revertLane = update.revertLane;
if (!enableAsyncActions || revertLane === NoLane) {
// 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.
// 📌 newBaseQueue가 비어있지 않으므로,
// 그 다음에 오는 업데이트들은 나중에 사용하기 위해 모두 stash 된다.
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,
revertLane: NoLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// Check if this update is part of a pending async action. If so,
// we'll need to suspend until the action has finished, so that it's
// batched together with future updates in the same action.
if (updateLane === peekEntangledActionLane()) {
didReadFromEntangledAsyncAction = true;
}
} else {
// This is an optimistic update. If the "revert" priority is
// sufficient, don't apply the update. Otherwise, apply the update,
// but leave it in the queue so it can be either reverted or
// rebased in a subsequent render.
...
}
// Process this update.
const action = update.action;
if (shouldDoubleInvokeUserFnsInHooksDEV) {
reducer(newState, 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);
}
}
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)) {
// 📌 리렌더링 중에 변경된 state가 없다면, bailout(NOT early bailout)이 발생한다.
markWorkInProgressReceivedUpdate();
// Check if this update is part of a pending async action. If so, we'll
// need to suspend until the action has finished, so that it's batched
// together with future updates in the same action.
if (didReadFromEntangledAsyncAction) {
...
}
}
hook.memoizedState = newState; // 📌 드디어 새로운 state가 set 되었다.
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast; // 📌 새로운 baseQueue가 다음 리렌더링을 위해 set된다.
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);
// 📌 새로운 state가 생겼다! dispatch()는 stable하다.
return [hook.memoizedState, dispatch];
}
setState()
는 이 queue를 이용해 상태를 업데이트한다.useState()
를 이용하여 state 값을 업데이트해준다.JSer가 그린 useState() 순서도 슬라이드를 보면 이해에 큰 도움이 된다!
🔗 https://jser.dev/2023-06-19-how-does-usestate-work#how-usestate---works-internally
react.dev에서 나열해둔 caveats가 왜 존재하는지 이해해보자.
The
set
function only updates the state variable for the next render. If you read the state variable after calling theset
function, you will still get the old value that was on the screen before your call.
setState()
가 다음 tick에 있는 re-render를 예약하기 때문에 동기적으로 작동하지 않으며, state 업데이트는 setState()
가 아니라 useState()
에서 이루어지기 때문에 업데이트된 값은 다음 렌더 때만 얻을 수 있다.
If the new value you provide is identical to the current
state
, as determined by anObject.is
comparison, React will skip re-rendering the component and its children. This is an optimization. Although in some cases React may still need to call your component before skipping the children, it shouldn’t affect your code.
다음 퀴즈를 풀어보자. https://bigfrontend.dev/react-quiz/useState
같은 값을 set 하는데 왜 리렌더링이 일어나는 것일까?
dispatchSetState()
안에 있는 eager bailout 조건 때문이다.
💻 src: ReactFiberHooks.js
if (
fiber.lanes === NoLanes &&
// 📌 이 조건 하에서는 state가 변경되지 않았을 때, 리렌더링 예약을 하지 않는다.
(alternate === null || alternate.lanes === NoLanes)
) {
state 변경이 없다는 것을 가장 잘 확인할 수 있는 방법은, pending update queue와 hook의 baseQueue가 비어있는지 확인하는 것이다. 하지만 현재 코드에서는 실제로 리렌더링을 시작하기 전까지는 참인지 여부를 확인할 수 없다.
따라서 fiber nodes에 업데이트가 없는지 확인하는 곳으로 이동한다. 업데이트가 queue에 추가되면 fiber가 dirty로 표시되므로, 리렌더링이 시작될 때까지 기다릴 필요가 없다. (bailout)
하지만 부작용이 있다.
💻 src: ReactFiberConcurrentUpdates.js
fiber.lanes = mergeLanes(fiber.lanes, lane);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
업데이트를 queue에 추가할 때, current와 alternate fibers 모두 lanes로 dirty 표시된다. 이는 필요한 작업인데, dispatchSetState()
가 source fiber에 바인딩되어있으므로, current와 alternate을 모두 업데이트하지 않으면 업데이트 처리를 보장할 수 없기 때문이다.
하지만 lanes을 지우는 건 beginWork()
에서만 일어나고, 이는 실제 리렌더링이다.
💻 src: ReactFiberBeginWork.js
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
...
// Before entering the begin phase, clear pending update priority.
workInProgress.lanes = NoLanes;
...
}
그러므로 업데이트가 한 번 예약되면, 최소한 두 차례의 리렌더링 이후에 dirty lanes flags가 완전히 없어진다.
단계는 대략 다음과 같다.
useState()
의 source fiber다.setState(true)
→ true
와 false
는 다르기 때문에, early bailout이 일어나지 않는다.beginWork()
에서 lanes이 없어진다.setState(true)
→ 둘 중 한 fiber는 clean하지 않으므로, early bailout은 여전히 일어나지 않는다.beginWork()
에서 lanes이 없어진다.bailoutHooks()
에서 current fiber의 lanes가 삭제되고 bailout(NOT early bailout)이 발생한다.setState(true)
→ 이제 두 fibers 모두 clean하므로, early bailout을 할 수 있다!state가 변경되지 않았지만, 리렌더링이 발생하는 이슈를 해결하려면, fiber architecture와 hook의 작동 방식을 변경해야 해서 비용이 많이 들 수도 있다. 이미 discussion이 있었으며, 대부분의 경우 해를 끼치지 않기 때문에 React 팀에서 이를 고칠 의도는 없어 보인다.
React는 필요할 경우 리렌더링을 한다는 점을 명심해야 하며, 성능 트릭이 항상 작동한다고 가정해서는 안 된다.
React batches state updates. It updates the screen after all the event handlers have run and have called their
set
functions. This prevents multiple re-renders during a single event. In the rare case that you need to force React to update the screen earlier, for example to access the DOM, you can useflushSync
.
업데이트는 처리되기 전에 stash 되며, 한 번에 처리된다.