파훅이다.
React를 처음 배울 때, useRef
와 useEffect
등 리액트 기본 훅도 같이 알게된다. 익숙하게 느껴지다보니 useState
처럼 거부감없이 사용하고 있었는데, 공식문서에서 두 훅 모두 필요할 때만 사용하라는 경고가 있는 것을 이제야 알게되어 훅들에 대한 지식을 점검해보려 한다.
먼저 서로 유사한 훅인 useState
와 useReducer
에 대해 알아본다.
훅들에 대해 세부적으로 알아보기 전에 리액트의 렌더링 과정을 먼저 대략적으로 훑어본다. 컴포넌트의 실행은 리액트의 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 트리를 의미한다. current
가 null
이라는 것은 첫 렌더링을 의미하므로 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)
컴포넌트 실행 전에 속성을 초기화하고 있는 workInProgress
는 current
와 반대 개념의 VDOM 트리로, 다음 DOM에 적용되기 위해 현재 렌더링 작업이 이루어진다. 속성 중 memoizedState
는 상태와 연관이 있는 훅들의 리스트가 저장되고 updateQueue
는 useEffect
처럼 이펙트를 유발하는 훅의 실행 여부가 기록된다.
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)
컴포넌트 안에서 처음으로 사용되는 훅이라면 currentlyRenderingFiber
의 memoizedState
에 추가된다. (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)
queue
는 dispatchSetState
라는 함수에 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
을 인자에서 가져오는데, 인자 중 fiber
와 queue
는 방금 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 반영이 두 번이나 되어 비효율적일 것이 자명하다.
잘 알려진 사실이지만 useState
는 useReducer
에 기본 리듀서를 사용해서 구현되어있다.
/**@ 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
속성에만 추가했다. 업데이트 때는 baseState
와 baseQueue
라는 속성에 업데이트 객체를 추가하거나 소비한다.
baseQueue
는 우선순위에 밀려 대기중인 업데이트들의 큐이다. (16)
pendingQueue
는 일단 baseQueue
에 병합된 뒤 루프문 안쪽 처리 과정을 거친다. (17)
UI의 반응성을 위해 바로 상태에 반영해야하는 동기적 업데이트는 현재 상태값인 memoizedState
를 먼저 업데이트한다.
(18)
baseState
는 동기적 업데이트가 적용되기 직전의 memoizedState
값을 저장한다. 이는 rebase 과정에서 기준으로 사용된다. 동기적 업데이트가 미리 적용되었더라도 업데이트가 큐에 추가된 순서로 반영되어야 개발자가 의도한 대로 최종 상태는 나타날 수 있다. baseQueue
에 대기중이던 업데이트는 우선순위가 충족되어 반영될 때 memoizedState
가 아닌 baseState
를 기반으로 상태를 갱신한다.
위 내용까지만 보면 먼저 반영되었던 동기적 업데이트의 결과가 버려질 것 같지만, 동기적 업데이트는 미리 반영될 때 baseQueue
에서 삭제되지 않는다. 나중에 순서에 맞게 변화된 memoizedState
값 기반으로 다시 업데이트를 반영하면서 rebase가 진행된다. (19)
동기적 업데이트 시점에 대기중인 큐가 없어 바로 상태에 적용되거나 낙관적 업데이트일 때만 baseQueue
에서 삭제될 수 있다.
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
조건문의
is
는Object.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
을 의미하는 것으로 보인다.
dispatchSetState
의 eagerly bailout
부분 주석에 설명된 대로 eagerly bailout
은 이전에 사용된 리듀서가 변하지 않았다는 걸 전제로 한다. 그런데 useReducer
에 전달되는 리듀서는 컴포넌트 render phase에서 props 같은 외부 요인이 결합된 채로 사용되는 케이스가 있어왔다고 한다. 이로 인해 eagerly bailout
과정에서 불필요한 상태 비교나 메모리 누수 등의 문제가 우려되었던 것으로 보인다. 때문에 useReducer
에서 eagerly bailout
과정은 제거되었고, 외부 요인이 결합할 수 없는 기본 리듀서를 사용한 useState
에만 남았다.
useReducer
는 원래도 성능이 아닌 가독성과 개발 용이성을 위한 것이었는데, 코드 상으로도useState
와 성능 차이가 있다는 점을 알게되었다.useState
에서useReducer
로의 전환은 깊은 고민이 선행되어야 할 것 같다.