useEffect와 useLayoutEffect의 차이

2022-09-25

예전에 데이터에 따라 DOM 조작이 필요했었는데, 이때 화면의 깜빡임을 없애기 위해 useEffect 대신 useLayoutEffect를 사용했었다.

그러나 정확히 이 둘의 차이가 무엇인지 생각해보면, 대답할 수 없었다.

useEffect

React의 render phase에 따르면, 변형(mutations), 구독(subscriptions), 타이머, 로깅 등의 사이드 이펙트들이 컴포넌트 함수 내부에 있어서는 안된다. (추후 다른 글로 작성할 예정)

이를 해결하기 위해 useEffect를 사용하는데, useEffect는 화면 렌더링이 완료된 후 혹은 어떤 값이 변경되었을 때 사이드 이펙트를 수행한다.

실행 시점

useEffect로 전달된 함수는 layout과 paint가 완료된 후에 비동기적으로 실행된다.

이때 만약 DOM에 영향을 주는 코드가 있을 경우, 사용자는 화면의 깜빡임과 동시에 화면 내용이 달라지는 것을 볼 수 있다. 중요한 정보일 경우, 화면이 다 렌더되기 전에 동기화해주는 것이 좋은데, 이를 위해 useLayoutEffect라는 훅이 나왔으며, 기능은 동일하되 실행 시점만 다르다.

React 18부터는 useEffect에서도 layout과 paint 전에 동기적으로 함수를 실행할 수 있는 flushSync라는 함수가 추가되었다. 하지만 강제로 실행하는 것이다보니, 성능상 이슈가 있을 수 있다.

useLayoutEffect

useLayoutEffect는 useEffect와 동일하지만, 렌더링 후 layout과 paint 전에 동기적으로 실행된다.

때문에 설령 DOM을 조작하는 코드가 존재하더라도, 사용자는 깜빡임을 보지 않는다.

예제

간단하게 DOM을 조작하는 코드를 만들었다.

버튼 클릭 시, 버튼의 bottom을 가져와 텍스트가 위치할 top을 계산하여 화면에 보여주는 것으로, useEffct로 하느냐 useLayoutEffect로 하느냐의 차이만 존재한다.

그런데 너무 빨라서 두 개의 차이를 잘 보지 못할 수 있는데, 클릭한 것을 0.25배속하여 만들어보았다.

예시

0.25배속이기 때문에 인내심을 요한다

사실 0.25배속으로 해도 두 개의 차이가 크게 나타나지 않는다. 찰나의 순간 깜빡임이 있느냐 없느냐의 차이다.

따라서 스크롤 위치를 찾거나 어떤 element의 스타일 요소를 변경하는 등 직접적으로 DOM을 조작하는 곳 제외하고는 useEffect를 사용하는 것을 추천한다. 공식 문서에서도 useEffect를 먼저 사용한 후에, 문제가 생긴다면 그때 useLayoutEffect를 사용하는 것을 추천하고 있다.

서버 렌더링에서 useLayoutEffect 사용하기

Next.js와 같은 서버 렌더링을 사용하는 경우, 자바스크립트가 모두 다운로드 될 때까지 useEffect와 useLayoutEffect 그 어느 것도 실행되지 않는다. 따라서 서버에서 렌더링되는 컴포넌트에서 useLayoutEffect를 사용하는 경우, React에서 경고를 띄운다.

Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See <https://fb.me/react-uselayouteffect-ssr> for common fixes.

useLayoutEffect는 페인트 전에 실행되기 때문에 서버에서 렌더되는 화면과 클라이언트에서 렌더되는 화면이 다를 수 있다. 따라서 useLayoutEffect는 오직 클라이언트 사이드에서만 실행되어야 한다는 경고 메세지다.


위 에러에서 알려주는 링크를 따라가보면 uselayouteffect-ssr.md에 따르면, 두 가지 방법을 알려준다.

첫 번째는 useEffect 사용하는 방법이다.

function MyComponent() {
  useEffect(() => {
    // ...
  });
}

두 번째는 해당 컴포넌트를 lazy하게 로드하는 방법이다.

function Parent() {
  const [showChild, setShowChild] = useState(false);

  // 클라이언트 사이드의 hydration 이후에 보여주기
  useEffect(() => {
    setShowChild(true);
  }, []);

  if (!showChild) {
    return null;
  }

  return <Child {...props} />;
}

function Child(props) {
  useLayoutEffect(() => {
    // ...
  });
}

이 외에 세 번째 방법으로는 useIsomorphicLayoutEffect 훅을 사용할 수 있다.

import { useEffect, useLayoutEffect } from 'react';

const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

export default useIsomorphicLayoutEffect;

서버일 경우 useLayoutEffect를 useEffect로 변경헌다.

코드로 알아보기

그렇다면 코드상으로는 어떤 부분이 다를까?

ReactHooks.js
export function useEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, deps);
}

export function useLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  const dispatcher = resolveDispatcher();
  return dispatcher.useLayoutEffect(create, deps);
}

useEffectuseLayoutEffect는 실행 시점만 다를 뿐 같은 로직이기 때문에 코드만 봤을 때는 차이가 없다.


다음에는 어떤 코드를 봐야할까 고민 하던 중, react-reconciler의 Dispatcher를 봤지만, 타입만 명시해줄 뿐 특별히 다른건 없었다.

그러다 해당 파일 상단에 HookType이 ReactFiberHooks.old.js로 이동했다는 주석을 보고 동일 계층의 파일들을 보니 ReactFiberHooks.new.js도 있어서 해당 파일을 보기로 했다.

ReactFiberHooks.new.js
const HooksDispatcherOnMount: Dispatcher = {
  // ...생략
  useEffect: mountEffect,
  useLayoutEffect: mountLayoutEffect,
};

const HooksDispatcherOnUpdate: Dispatcher = {
  // ...생략
  useEffect: updateEffect,
  useLayoutEffect: updateLayoutEffect,
};

const HooksDispatcherOnRerender: Dispatcher = {
  // ...생략
  useEffect: updateEffect,
  useLayoutEffect: updateLayoutEffect,
};

다른 점을 찾았다.

useEffect는 mountEffect와 updateEffect를, useLayoutEffect는 mountLayoutEffect와 updateLayoutEffect를 실행한다. mount와 update 함수의 구조는 거의 비슷하여 해당 글에서는 mount만 다룬다.

ReactFiberHooks.new.js
// useEffect
function mountEffect(create: () => (() => void) | void, deps: Array<mixed> | void | null): void {
  return mountEffectImpl(PassiveEffect | PassiveStaticEffect, HookPassive, create, deps);
}

// useLayoutEffect
function mountLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  const fiberFlags: Flags = UpdateEffect | LayoutStaticEffect;
  return mountEffectImpl(fiberFlags, HookLayout, create, deps);
}

그리고 이 둘의 차이는 return하는 함수의 첫 번째(fiberFlags), 두 번째(hookFlags) 인자가 달랐다.

ReactFiberHooks.new.js
function mountEffectImpl(fiberFlags, hookFlags, create, deps: Array<mixed> | void | null): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(HookHasEffect | hookFlags, create, undefined, nextDeps);
}

FiberFlags

ReactFiberFlags.js
export type Flags = number;

export const Update = /*                       */ 0b000000000000000000000100; // => UpdateEffect
export const Passive = /*                      */ 0b000000000000010000000000; // => PassiveEffect

export const LayoutStatic = /*                 */ 0b001000000000000000000000; // => LayoutStaticEffect
export const PassiveStatic = /*                */ 0b010000000000000000000000; // => PassivStaticEffect

HookFlags

ReactHookEffectTags.js
export type HookFlags = number;

export const Layout = /*    */ 0b01000; // => HookLayout
export const Passive = /*   */ 0b10000; // => HookPassive

그리고 이 fiberFlags와 hookFlags는 pushEffect 함수의 첫 번째 인자인 tag로 들어가고, 해당 함수는 componentUpdateQueue로 업데이트 할 컴포넌트를 Flag를 통해 Queue로 관리하는 것 같다.

ReactFiberHooks.new.js
function pushEffect(tag, create, destroy, deps: Array<mixed> | void | null) {
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue =
    (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    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;
}

그리고 초기값으로 할당된 currentlyRenderingFiber는 renderWithHooks 함수에서 현재 동작중인 훅을 기록하는 것으로 보인다.

ReactFiberHooks.new.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;
}

결론

useEffect와 useLayoutEffect가 어떻게 다르고, 어떤 코드로 이루어졌는지 알아보았다.

그런데 사실 어떤 원리로 동작하는지 정확하게 파악하려면, React의 Fiber 알고리즘과 Reconciliation까지 이해해야 정확한 글을 작성할 수 있을 것 같다. (결국 수박 겉핥기 글이 되어버렸다..)


참고