Amada Coding Club

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

Mash-Up/Study

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

아마다회장 2024. 5. 7. 18:37

https://jser.dev/2023-07-08-how-does-useeffect-work 의 글을 직역했습니다.

useEffect()는 React에서 useState()를 제외하고 가장 많이 사용되는 훅일 수 있습니다. 매우 강력하지만 때때로 혼란스러울 수 있으므로 내부적으로 어떻게 작동하는지 알아봅시다.

React.useEffect(() => {
	 //...
}[deps])

 

1. 초기 마운트에서의 useEffect()

useEffect()는 초기 마운트 시 mountEffect()를 사용합니다.

function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return mountEffectImpl(
    PassiveEffect | PassiveStaticEffect,
    /*
    ⬆️이 플래그(PassiveEffect | PassiveStaticEffect)는 Layout Effect와의 차이점을 구분하는 데 중요합니다. 
    PassiveStaticEffect란 무엇인가요? 
    이 부분은 다른 에피소드에서 다룰 가치가 있습니다.
    */

    HookPassive,
    create,
    deps,
  );
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = mountWorkInProgressHook();
  /*
  ⬆️새로운 훅을 생성합니다.
  */

  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
  /*
  ⬆️pushEffect()는 Effect 객체를 생성한 다음 hook에 설정합니다.
  */

    HookHasEffect | hookFlags,
    /*
    ⬆️이 플래그(HookHasEffect)는 중요하며, 초기 마운트 시 이 effect를 실행해야 함을 의미합니다.
    */

    create,
    undefined,
    nextDeps,
  );
}
function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,
    /*
    ⬆️tag 는 이 효과를 실행해야 하는지 여부를 표시하는 데 사용됩니다.
    */

    create,
    /*
    ⬆️전달되는 콜백
    */

    destroy,
    /*
    ⬆️콜백에서 반환한 정리 함수
    */

    deps,
    /*
    ⬆️전달하는 deps 배열
    */

    // Circular
    next: (null: any),
    /*
    ⬆️하나의 컴포넌트에 여러 effect가 있을 수 있습니다.
    */

  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    /*
    ⬆️Effect는 fiber의 updateQueue에 저장됩니다.
    이건 hook의 memoizedState와 다릅니다.
    */

    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;
}

초기 마운트의 경우, useEffect()가 필요한 플래그를 사용하여 Effect 객체를 생성하는 것을 볼 수 있습니다. 다른 타이밍에 처리될 것입니다.

 

2. 리렌더에서의 useEffect()

function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = updateWorkInProgressHook();
  /*
  ⬆️현재 진행중인 훅을 가지고 옵니다.
  */

  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;
  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    /*
    ⬆️effect hook의 memoizedState은 effect 객체라는 것을 기억하세요.
    */

    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
      /*
      ⬆️deps가 변경되지 않으면 Effect 객체를 다시 생성하기만 하면 됩니다. 
      여기서 다시 생성해야 하는 이유는 업데이트 큐를 다시 생성하고 업데이트된 create()를 가져와야 하기 때문일 수 있습니다. 
      여기서는 이전 destroy() 함수를 사용하고 있음을 주목하세요.
      */
    }
  }
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    /*
    ⬆️deps가 변경되면 HookHasEffect는 이 효과를 실행해야 함을 표시합니다.
    */

    create,
    destroy,
    nextDeps,
  );
}

 

혼란스러운 deps 배열이 어떻게 작동하는지 알 수 있습니다. 다시 렌더링할 때는 어떤 경우에도 Effect 객체를 다시 생성하지만, Deps가 변경되면 생성된 Effect가 이전 정리 함수를 사용하여 실행되도록 표시됩니다.

 

3. effects는 언제 어떻게 실행되고 정리되나요? 

위에서 우리는 useEffect()가 fiber node에 추가 데이터 구조를 생성할 뿐이라는 것을 알았습니다. 이제 이러한 Effect 객체가 어떻게 처리되는지 알아볼 필요가 있습니다.

 

3.1 passive effects의 실행은 commitRoot()에서 트리거됩니다.

두 fiber tree(reconciliation)를 비교하여 서로 다른 결과를 얻은 후 커밋 단계에서 호스트 DOM에 변경 사항을 반영합니다. 패시브 이펙트의 플러싱을 시작한 코드를 쉽게 찾을 수 있습니다.

function commitRootImpl(
  root: FiberRoot,
  recoverableErrors: null | Array<CapturedValue<mixed>>,
  transitions: Array<Transition> | null,
  renderPriorityLevel: EventPriority,
) {
  // 보류 중인 패시브 효과가 있는 경우 콜백을 예약하여 처리합니다. 
  // 이 작업을 가능한 한 빨리 수행하여 커밋 단계에서 예약될 수 있는 
  // 다른 모든 작업보다 먼저 대기열에 추가합니다. (#16714 참조).
  // TODO: 패시브 효과 콜백을 예약하는 다른 모든 위치를 삭제합니다. 지금은 중복됩니다.
  if (
    (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
    (finishedWork.flags & PassiveMask) !== NoFlags
  ) {
    if (!rootDoesHavePassiveEffects) {
      rootDoesHavePassiveEffects = true;
      pendingPassiveEffectsRemainingLanes = remainingLanes;
      // workInProgressTransitions를 덮어쓸 수 있으므로 처리될 때까지 pendingPassiveTransitions에 저장하고 싶습니다. 
      // setTimeout으로 커밋을 스로틀링하면 이전 렌더링과 커밋 사이에 workInProgressTransitions가 변경되었을 수 있으므로 
      // 이를 commitRoot에 인수로 전달해야 합니다.
      pendingPassiveTransitions = transitions;
      scheduleCallback(NormalSchedulerPriority, () => {
        flushPassiveEffects(); //💫
        // 이 렌더링 트리거 패시브 이펙트: 트리의 노드(호스트 루트, 캐시 경계 등)에서 참조할 수 있는 캐시 풀이 해제되지 않도록 루트 캐시 풀 *후* 패시브 이펙트 발동을 해제합니다.
        return null;
      });
      /*
      ⬆️여기서는 useEffect()로 생성된 패시브 이펙트를 플러시합니다.
      이것은 즉시 플러싱을 하지 않고 다음 tick에 플러싱을 예약합니다.
      자세한 내용은 React 스케줄러의 작동 방식을 참고하세요.
      */

    }
  }
  ...
}

 

3.2 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;
  // TODO: 이것은 때때로 rootWithPendingPassiveEffects와 동기화되지 않습니다.
  // 원인을 파악하고 수정하세요. 알려진 문제를 일으키지는 않지만(프로파일링에만 사용되기 때문일 수 있습니다) 
  // 리팩터 위험입니다.
  pendingPassiveEffectsLanes = NoLanes;
  const prevExecutionContext = executionContext;
  executionContext |= CommitContext;
  commitPassiveUnmountEffects(root.current);
  commitPassiveMountEffects(root, root.current, lanes, transitions);
  /*
  ⬆️여기에서는 콜백이 실행되기 전에 효과 정리가 먼저 실행되는 것을 명확하게 볼 수 있습니다.
  */

  ...
}

 

3.3 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: {
      recursivelyTraversePassiveUnmountEffects(finishedWork);
      /*
      ⬆️ 자식의 effects가 먼저 정리되는 것을 볼 수 있습니다.
      */

      if (finishedWork.flags & Passive) {
        commitHookPassiveUnmountEffects( //💫
          finishedWork,
          finishedWork.return,
          HookPassive | HookHasEffect, //💫 
          /*
          ⬆️이 플래그는 deps가 변경되지 않으면 콜백이 실행되지 않도록 합니다.
          */

        );
      }
      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);
    //🔥
    /*
    ⬆️업데이트 큐의 모든 effect들을 반복해서 살펴보고 플래그별로 필요한 effect를 필터링하기만 하면 됩니다.
    */

  }
}
function safelyCallDestroy(
  current: Fiber,
  nearestMountedAncestor: Fiber | null,
  destroy: () => void,
) {
  try {
    destroy();
  } catch (error) {
    captureCommitPhaseError(current, nearestMountedAncestor, error);
  }
}

 

3.4 commitPassiveMountEffects()

commitPassiveMountEffects()도 같은 방식으로 작동합니다.

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 {
  // 이 함수를 업데이트할 때는 화면 밖 트리가 숨김에서 보임으로 전환되거나 
  // 숨겨진 트리 내부에서 효과를 토글할 때 대부분의 동일한 작업을 수행하는 
  // reconnectPassiveEffects도 업데이트하세요.
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      recursivelyTraversePassiveMountEffects(
      /*
      ⬆️자식의 effects가 먼저 실행되는 것을 볼 수 있습니다.
      */

        finishedRoot,
        finishedWork,
        committedLanes,
        committedTransitions,
      );
      if (flags & Passive) {
        commitHookPassiveMountEffects(
          finishedWork,
          HookPassive | HookHasEffect,
          /*
          ⬆️이 플래그는 deps가 변경되지 않으면 콜백이 실행되지 않도록 합니다.
          */

        );
      }
      break;
    }
    ...
  }
}
function commitHookPassiveMountEffects(
  finishedWork: Fiber,
  hookFlags: HookFlags,
) {
  if (shouldProfile(finishedWork)) {
    startPassiveEffectTimer();
    try {
      commitHookEffectListMount(hookFlags, finishedWork);
    } catch (error) {
      captureCommitPhaseError(finishedWork, finishedWork.return, error);
    }
    recordPassiveEffectDuration(finishedWork);
  } else {
    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 is run here!

        inst.destroy = destroy;
      }
      effect = effect.next;
    } while (effect !== firstEffect);
    /*
    다시 필요한 effect를 필터링하고 다음을 실행합니다.
    */

  }
}

 

4. Summary

소스 코드를 살펴본 결과, useEffect()의 내부는 매우 간단하다는 것을 알 수 있었습니다.

1. useEffect()는 파이버에 저장되는 Effect 객체를 생성합니다.

  - Effect에는 실행이 필요한지 여부를 나타내는 태그가 있습니다.

  - Effect에는 우리가 전달하는 첫 번째 인수인 create()가 있습니다.

  - Effect에는 create()의 정리 함수인 destroy()가 있으며, create()가 실행될 때만 설정됩니다.

2. useEffect()는 매번 새로운 Effect 객체를 생성하지만, deps 배열이 변경되면 다른 태그를 설정합니다.

3. 호스트 DOM에 업데이트를 커밋할 때, 다음 tick의 작업은 태그를 기반으로 모든 Effect를 다시 실행하도록 예약됩니다.

  - 자식 컴포넌트의 효과가 먼저 처리됩니다.
  - 그리고 정리가 먼저 실행됩니다.

 

문제 풀이

console.log 출력 순서 맞추기

1. 

import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'

function App() {
  const [state, setState] = useState(0)
  console.log(1)
  
  useEffect(() => {
    console.log(2)
  }, [state])

  Promise.resolve().then(() => console.log(3))

  setTimeout(() => console.log(4), 0)

  const onClick = () => {
    console.log(5)
    setState(num => num + 1)
    console.log(6)
  }
  return <div>
    <button onClick={onClick}>click me</button>
  </div>
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)

setTimeout(() => userEvent.click(screen.getByText('click me')), 100)

정답:  1 2 3 4 5 6 1 2 3 4

 

2. 

import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom'

function App() {
  const [state, setState] = useState(0)
  console.log(state)

  useEffect(() => {
    setState(state => state + 1)
  }, [])

  useEffect(() => {
    console.log(state)
    setTimeout(() => {
      console.log(state)
    }, 100)
  }, [])

  return null
}

ReactDOM.render(<App/>, document.getElementById('root'))

정답: 0 0 1 0

 

3.

import React, { useState, useRef, useEffect } from 'react';
import ReactDOM from 'react-dom'

function App() {
  const [show, setShow] = useState(true)
  return <div>
    {show && <Child unmount={() => setShow(false)} />}
  </div>;
}

function Child({ unmount }) {
  const isMounted = useIsMounted()
  useEffect(() => {
    console.log(isMounted)
    Promise.resolve(true).then(() => {
      console.log(isMounted)
    });
    unmount(); 
  }, []);

  return null;
};

function useIsMounted() {
  const isMounted = useRef(false);

  useEffect(() => {
    isMounted.current = true;
    return () => isMounted.current = false;
  }, []);

  return isMounted.current;
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)

정답: false false

useIsMounted의 useEffect는 언제 실행 되는지?

 

4. 

import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'

function App() {
  const [state] = useState(0)
  console.log(1)
  
  const start = Date.now()
  while (Date.now() - start < 50) {
    window.timestamp = Date.now()
  }
  
  useEffect(() => {
    console.log(2)
  }, [state])

  Promise.resolve().then(() => console.log(3))

  setTimeout(() => console.log(4), 0)

  return null
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)

정답: 1 3 4 2

while이 마칠 때까지 useEffect는 미뤄진다.

 

5. 

'infiniteLoopProtection:false'
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { screen } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'

function App() {
  const [state, setState] = useState(0)
  console.log(1)
  
  const start = Date.now()
  while (Date.now() - start < 50) {
    window.timestamp = Date.now()
  }

  useEffect(() => {
    console.log(2)
  }, [state])

  Promise.resolve().then(() => console.log(3))

  setTimeout(() => console.log(4), 0)

  const onClick = () => {
    console.log(5)
    setState(num => num + 1)
    console.log(6)
  }
  return <div>
    <button onClick={onClick}>click me</button>
  </div>
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)

setTimeout(() => userEvent.click(screen.getByText('click me')), 100)

정답: 1 3 4 2 5 6 1 2 3 4

 

6.

'infiniteLoopProtection:false'

import React, { useState, useEffect, useLayoutEffect} from 'react'
import ReactDOM from 'react-dom'

function App() {
  const [state, setState] = useState(0)
  console.log(1)
  
  const start = Date.now()
  while (Date.now() - start < 50) {
    window.timestamp = Date.now()
  }
  
  useEffect(() => {
    console.log(2)
  }, [state])

  Promise.resolve().then(() => console.log(3))

  setTimeout(() => console.log(4), 0)

  useLayoutEffect(() => {
    console.log(5)
    setState(state => state + 1)
  }, [])

  return null
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App/>)

정답: 1 5 2 1 2 3 3 4 4