JSer.dev의 React Internals Deep Dive를 번역하여 정리한 글입니다.
⚠️ React@19의 commit 7608516을 기반으로 작성되었으며, 최신 버전에서는 구현이 변경되었을 수 있습니다.
useEffect()
는 useState()
다음으로 가장 많이 사용되는 hook일 것이다. 내부 동작 원리를 알아보자.
useEffect(() => {
// ...
}, [deps]);
useEffect()
실행 시 render와 commit 단계에서 호출되는 주요 함수는 다음과 같다.
mountEffect()
initial mountupdateEffect()
commitPassiveUnmountEffects()
(only if re-render) ⇒ commitPassiveMountEffects()
(both initial mount & re-render)useEffect()
in initial mount.useEffect()
는 initial mount에서 mountEffect()
를 사용한다.
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return mountEffectImpl(
PassiveEffect | PassiveStaticEffect, // 📌 이 flag는 Layout Effects와의 차이점을 구분하는 데 중요하다.
HookPassive,
create,
deps,
);
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
// 📌 새로운 hook을 생성한다.
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber.flags |= fiberFlags;
// 📌 pushEffect()가 만든 Effect 객체를 hook에 설정(set)한다.
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags, // 📌 HookHasEffect flag는 initial mount에 이 effect를 실행해야 한다는 것을 의미하기에 중요하다.
create,
undefined,
nextDeps,
);
}
function pushEffect(tag, create, destroy, deps) {
const effect: Effect = {
tag, // 📌 tag는 이 effect의 실행 여부를 표시하는 데 사용되기 때문에 중요하다.
create, // 📌 전달한 callback이다.
destroy, // 📌 callback에서 return한 cleanup 함수다.
deps, // 📌 전달한 dependency array다.
// Circular
next: (null: any), // 📌 하나의 컴포넌트에 여러 effect가 있는 경우, 이를 연결(chain)한다.
};
let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
// 📌 Effect는 fiber에 있는 updateQueue에 저장된다.
// 이는 hooks의 memoizedState와 다르다는 점을 유의하자.
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
Initial mount의 경우, useEffect()
가 필요한 flag를 사용하여 Effect 객체를 생성하는 것을 볼 수 있다. 여기에서 생성된 passive effects는 commit phase에서 스케줄러에 의해 처리된다.
useEffect()
in re-renderfunction updateEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
// 📌 현재 current를 가져온다.
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
// 📌 effect hook의 memoizedState가 Effect 객체라는 사실을 기억하자.
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// 📌 deps가 변경되지 않으면, 아무것도 하지 않고 Effect 객체만 다시 생성한다.
// Effect 객체를 다시 생성하는 이유는, updateQueue를 다시 생성하고
// 업데이트된 create()를 가져와야 하기 때문이다.
// 여기에서 이전 destroy()를 사용하고 있다는 점을 확인하자.
if (areHookInputsEqual(nextDeps, prevDeps)) {
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags, // 📌 deps가 변경되면, HookHasEffect는 이 effect가 실행되어야함을 표시한다.
create,
destroy,
nextDeps,
);
}
deps 배열이 어떻게 작동하는지 확인했다. re-render에서는 항상 Effect 객체를 다시 생성하고, deps가 변경된 경우에만 생성된 Effect에 이전 cleanup 함수를 사용하여 실행되어야 함을 표시한다.
useEffect()
는 단지 fiber node에 추가 데이터 구조를 생성할 뿐이라는 것을 알았다.
이제 이러한 Effect 객체들이 어떻게 처리되는지 살펴보자.
commitRoot()
💡 Passive Effect
두 가지 유형의 effect가 존재한다.
useEffect
= "effects"useLayoutEffect
= "layout effects"“effects”라고 하면 둘 중에 어떤 effect를 의미하는 건지 불명확할 때가 있어서,
useEffect
로부터 생성된 effects를 “passive effects”라고 부르기도 한다.Cf. https://github.com/reactwg/react-18/discussions/46#discussioncomment-847365
두 fiber 트리를 비교(reconciliation)하여 diffing 결과를 얻은 후, commit 단계에서 host DOM에 변경 사항을 반영해야 한다. passive effects의 flushing(한꺼번에 반영)을 시작하는 코드를 쉽게 찾을 수 있다.
function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
// If there are pending passive effects, schedule a callback to process them.
// Do this as early as possible, so it is queued before anything else that
// might get scheduled in the commit phase. (See #16714.)
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
pendingPassiveEffectsRemainingLanes = remainingLanes;
...
// 📌 여기에서 useEffect()로부터 생성된 passive effects를 flush한다.
// 스케줄러에서 지금 당장 말고, 다음 tick에서 flush하도록 예약한다.
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
// This render triggered passive effects: release the root cache pool
// *after* passive effects fire to avoid freeing a cache pool that may
// be referenced by a node in the tree (HostRoot, Cache boundary etc)
return null;
});
}
}
...
}
flushPassiveEffects()
function flushPassiveEffectsImpl() {
if (rootWithPendingPassiveEffects === null) {
return false;
}
// Cache and clear the transitions flag
const transitions = pendingPassiveTransitions;
pendingPassiveTransitions = null;
const root = rootWithPendingPassiveEffects;
const lanes = pendingPassiveEffectsLanes;
rootWithPendingPassiveEffects = null;
pendingPassiveEffectsLanes = NoLanes;
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
// 📌 여기에서 effect cleanup이 먼저 실행되고, callback이 나중에 실행된다는 것을 확인할 수 있다.
commitPassiveUnmountEffects(root.current);
commitPassiveMountEffects(root, root.current, lanes, transitions);
...
}
commitPassiveUnmountEffects()
export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
setCurrentDebugFiberInDEV(finishedWork);
commitPassiveUnmountOnFiber(finishedWork);
resetCurrentDebugFiberInDEV();
}
function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
// 📌 children의 effect가 먼저 clean up된다는 것을 알 수 있다.
recursivelyTraversePassiveUnmountEffects(finishedWork);
if (finishedWork.flags & Passive) {
commitHookPassiveUnmountEffects(
finishedWork,
finishedWork.return,
HookPassive | HookHasEffect, // 📌 HookHasEffect flag는 deps가 변경되지 않으면, callback이 실행되지 않도록 한다.
);
}
break;
}
...
}
}
function commitHookPassiveUnmountEffects(
finishedWork: Fiber,
nearestMountedAncestor: null | Fiber,
hookFlags: HookFlags,
) {
if (shouldProfile(finishedWork)) {
startPassiveEffectTimer();
commitHookEffectListUnmount(
hookFlags,
finishedWork,
nearestMountedAncestor,
);
recordPassiveEffectDuration(finishedWork);
} else {
commitHookEffectListUnmount(
hookFlags,
finishedWork,
nearestMountedAncestor,
);
}
}
function commitHookEffectListUnmount(
flags: HookFlags,
finishedWork: Fiber,
nearestMountedAncestor: Fiber | null,
) {
const updateQueue: FunctionComponentUpdateQueue | null =
(finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & flags) === flags) {
// Unmount
const inst = effect.inst;
const destroy = inst.destroy;
if (destroy !== undefined) {
inst.destroy = undefined;
safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
}
}
effect = effect.next;
} while (effect !== firstEffect);
// 📌 updateQueue의 있는 모든 Effect를 돌면서
// flag를 이용하여 필요한 것들을 필터링한다.
}
}
function safelyCallDestroy(
current: Fiber,
nearestMountedAncestor: Fiber | null,
destroy: () => void,
) {
try {
destroy();
} catch (error) {
captureCommitPhaseError(current, nearestMountedAncestor, error);
}
}
commitPassiveMountEffects()
commitPassiveMountEffects()
도 commitPassiveUnmountEffects()
와 같은 방식으로 작동한다.
export function commitPassiveMountEffects(
root: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
): void {
setCurrentDebugFiberInDEV(finishedWork);
commitPassiveMountOnFiber(
root,
finishedWork,
committedLanes,
committedTransitions,
);
resetCurrentDebugFiberInDEV();
}
function commitPassiveMountOnFiber(
finishedRoot: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
): void {
const flags = finishedWork.flags;
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
// 📌 children의 effect가 먼저 실행된다는 것을 알 수 있다.
recursivelyTraversePassiveMountEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
if (flags & Passive) {
commitHookPassiveMountEffects(
finishedWork,
HookPassive | HookHasEffect, // 📌 HookHasEffect flag는 deps가 변경되지 않으면, callback이 실행되지 않도록 한다.
);
}
break;
}
...
}
}
function commitHookPassiveMountEffects(
finishedWork: Fiber,
hookFlags: HookFlags,
) {
...
try {
commitHookEffectListMount(hookFlags, finishedWork);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
const updateQueue: FunctionComponentUpdateQueue | null =
(finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & flags) === flags) {
// Mount
const create = effect.create;
const inst = effect.inst;
const destroy = create(); // 📌 callback이 여기에서 실행된다!
inst.destroy = destroy;
}
effect = effect.next;
} while (effect !== firstEffect);
// 📌 여기에서도, 필요한 Effects만 필터링해서 실행한다.
}
}
useEffect()
의 작동 원리는 다음과 같다.
useEffect()
는 Effect 객체를 생성하고 fiber에 저장한다.
tag
를 통해 해당 Effect의 실행이 필요한지 여부를 나타낸다.create()
는 useEffect에 전달하는 첫 번째 인자인 callback이다.destroy()
는 create()
의 cleanup으로, create()
가 실행될 때만 설정된다.useEffect()
는 매번 새로운 Effect 객체를 생성하되, deps 배열이 변경될 때 다른 tag
를 설정한다.
host DOM에 업데이트를 commit할 때, 다음 tick의 job은 tag
를 기반으로 모든 Effects를 다시 실행하도록 예약된다.
React quizzes | BFE.dev - prepare for Front-End job interviews.