본문 바로가기
React-study/dil

[DIL] useLayoutEffect

by 어느새벽 2024. 6. 11.

* useLayoutEffect는 성능을 저하시킬 수 있다. 가급적이면 useEffect를 사용하는 것이 좋다.

 

useLayoutEffect는 브라우저가 화면을 다시 채우기 전에 실행되는 버전의 useEffect이다.

useLayoutEffect(setup, dependencies?)

 

useLayoutEffect(setup, dependencies?)

브라우저가 화면을 다시 그리기 전에 useLayoutEffect를 호출하여 레이아웃을 측정한다.

import { useState, useRef, useLayoutEffect } from 'react';

function Tooltip() {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
  }, []);
  // ...

 

매개변수

  • setup: Effect의 로직이 포함된 함수이다. 셋업 함수는 선택적으로 클린업 함수를 반환할 수 있다. 컴포넌트가 DOM에 추가되기 전에 React는 셋업 함수를 실행한다. 변경된 의존성으로 다시 렌더링할 때마다 리액트는 (클린업 함수를 정의한 경우) 먼저 이전 값으로 클린업 함수를 실행한 다음, 새 값으로 셋업 함수를 실행한다. 컴포넌트가 DOM에서 제거되기 전에 리액트는 클린업 함수를 한번 더 실행한다.
  • 선택적 dependencies : setup코드 내에서 참조된 모든 반응형 값의 목록이다. 반응형 값에는 props, state, 컴포넌트 본문 내부에서 직접 선언된 모든 변수와 함수가 포함된다. 린터가 리액트용으로 설정된 경우, 모든 반응형 값이 의존성으로 올바르게 지정되었는지 확인한다. 의존성 목록은 일정한 수의 항목을 가져야 하며 [dep1, dep2, dep3]와 같이 인라인으로 작성해야 한다. 리액트는 Object.is 비교를 사용하여 각 의존성을 이전 값과 비교한다. 이 인수를 생략하면 컴포넌트를 다시 렌더링할 때마다 Effect가 다시 실행된다.

반환값

useLayoutEffect는 undefined를 반환한다.

  • useLayoutEffect는 훅이므로 컴포넌트의 최상위 레벨 또는 자체 훅에서만 호출할 수 있다. 반복문이나 조건문 내부에서는 호출할 수 없다. 필요하다면 컴포넌트를 추출하고 Effect를 그곳으로 이동해야 한다.
  • Strict Mode가 켜져 있으면 리액트는 첫번째 실제 셋업 전에 개발 전용 셋업 + 클린업 사이클을 한번 더 실행한다. 이는 클린업 로직이 셋업 로직을 "미러링"하고 설정이 수행 중인 모든 작업을 중지하거나 취소하는지 확인하는 스트레스 테스트이다. 문제가 발생하면 클린업 함수를 구현해야 한다.
  • 의존성 중 일부가 컴포넌트 내부에 정의된 객체 또는 함수인 경우, Effect가 필요 이상으로 자주 다시 실행될 위험이 있다. 이 문제를 해결하려면 불필요한 객체 및 함수 의존성을 제거해야 한다. 또한 Effect 외부에서 state 업데이트와 비반응형 로직을 추출할 수 있다.
  • Effects 클라이언트에서만 실행된다. 서버 렌더링 중에는 실행되지 않는다.
  • useLayoutEffect 내부의 코드와 여기에서 예약된 모든 state 업데이트는 브라우저가 화면을 다시 그리는 것을 차단한다. 과도하게 사용하면 앱이 느려진다. 가급적이면 useEffect를 사용해야한다. 

사용법

브라우저에서 화면을 다시 그리기 전 레이아웃 측정하기 

대부분의 컴포넌트는 무엇을 렌더링할지 결정하기 위해 화면에서의 위치와 크기를 알 필요가 없다. 일부 JSX만 반환하기 때문이다. 그런 다음 브라우저는 해당 컴포넌트의 레이아웃(위치 및 크기)을 계산하고 화면을 다시 그린다. 

 

때론 그것만으로는 충분하지 않을 수 있다. 마우스오버 시 요소 옆에 툴팁을 표시하는 것을 상상해 본다. 공간이 충분하면 툴팁이 요소 위에 표시되어야 하지만 공간이 충분하지 않으면 아래에 표시되어야 한다. 툴팁을 올바른 최종 위치에 렌더링하려면 툴팁의 높이(즉, 상단에 표시하기에 충분한지 여부)를 알아야 한다.

 

이렇게 하려면 두번의 패스로 렌더링해야 한다.

  1. 툴팁을 원하는 위치에 렌더링한다(위치가 잘못된 경우에도).
  2. 높이를 측정하고 툴팁을 배치할 위치를 결정한다.
  3. 올바른 위치에 툴팁을 다시 렌더링한다. 

이 모든 작업은 브라우저가 화면을 다시 그리기 전에 이루어져야 한다. 사용자가 툴팁이 움직이는 것을 보지 않기를 원한다. 브라우저가 화면을 다시 그리기 전에 useLayoutEffect를 호출하여 레이아웃 측정을 수행한다.

function Tooltip() {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0); // You don't know real height yet
                                                         // 아직 실제 height 값을 모릅니다.

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height); // Re-render now that you know the real height
                              // 실제 높이를 알았으니 이제 리렌더링 합니다.
  }, []);

  // ...use tooltipHeight in the rendering logic below...
  // ...아래에 작성될 렌더링 로직에 tooltipHeight를 사용합니다...
}

 

단계별 작동 방식은 다음과 같다.

  1. Tooltip은 초기 tooltipHeight = 0으로 렌더링된다(따라서 툴팁의 위치가 잘못 지정될 수 있음).
  2. React는 이를 DOM에 배치하고 useLayoutEffect에서 코드를 실행한다.
  3. useLayoutEffect는 툴팁 콘텐츠의 높이를 측정하고 즉시 다시 렌더링을 촉발한다.
  4. Tooltip이 실제 tooltipHeight로 다시 렌더링된다(따라서 툴팁이 올바르게 배치된다).
  5. React가 DOM에서 이를 업데이트하면 브라우저에 툴팁이 최종적으로 표시된다.

아래 버튼 위로 마우스를 가져가면 툴팁이 맞는지 여부에 따라 툴팁의 위치가 어떻게 조정되는지 확인할 수 있다.

// App.js

import ButtonWithTooltip from './ButtonWithTooltip.js';

export default function App() {
  return (
    <div>
      <ButtonWithTooltip
        tooltipContent={
          <div>
            This tooltip does not fit above the button.
            <br />
            This is why it's displayed below instead!
          </div>
        }
      >
        Hover over me (tooltip above)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
      <div style={{ height: 50 }} />
      <ButtonWithTooltip
        tooltipContent={
          <div>This tooltip fits above the button</div>
        }
      >
        Hover over me (tooltip below)
      </ButtonWithTooltip>
    </div>
  );
}

//ButtonWithTooltip.js

import { useState, useRef } from 'react';
import Tooltip from './Tooltip.js';

export default function ButtonWithTooltip({ tooltipContent, ...rest }) {
  const [targetRect, setTargetRect] = useState(null);
  const buttonRef = useRef(null);
  return (
    <>
      <button
        {...rest}
        ref={buttonRef}
        onPointerEnter={() => {
          const rect = buttonRef.current.getBoundingClientRect();
          setTargetRect({
            left: rect.left,
            top: rect.top,
            right: rect.right,
            bottom: rect.bottom,
          });
        }}
        onPointerLeave={() => {
          setTargetRect(null);
        }}
      />
      {targetRect !== null && (
        <Tooltip targetRect={targetRect}>
          {tooltipContent}
        </Tooltip>
      )
    }
    </>
  );
}

//Tooltip.js

import { useRef, useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import TooltipContainer from './TooltipContainer.js';

export default function Tooltip({ children, targetRect }) {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height);
    console.log('Measured tooltip height: ' + height);
  }, []);

  let tooltipX = 0;
  let tooltipY = 0;
  if (targetRect !== null) {
    tooltipX = targetRect.left;
    tooltipY = targetRect.top - tooltipHeight;
    if (tooltipY < 0) {
      // It doesn't fit above, so place below.
      tooltipY = targetRect.bottom;
    }
  }

  return createPortal(
    <TooltipContainer x={tooltipX} y={tooltipY} contentRef={ref}>
      {children}
    </TooltipContainer>,
    document.body
  );
}


//TooltipContainer.js

export default function TooltipContainer({ children, x, y, contentRef }) {
  return (
    <div
      style={{
        position: 'absolute',
        pointerEvents: 'none',
        left: 0,
        top: 0,
        transform: `translate3d(${x}px, ${y}px, 0)`
      }}
    >
      <div ref={contentRef} className="tooltip">
        {children}
      </div>
    </div>
  );
}

 

Tooltip 컴포넌트가 두번의 패스로 렌더링되어야 하지만(먼저 tooltipHeight를 0으로 초기화한 다음 실제 측정된 높이로), 최종 결과만 볼 수 있다는 점에 유의해야 한다.

 

'React-study > dil' 카테고리의 다른 글

[DIL] useCallback  (0) 2024.06.18
[DIL] useMemo  (0) 2024.06.17
[DIL] useEffect  (0) 2024.06.04
[DIL] useRef  (0) 2024.05.30
[DIL] useContext  (0) 2024.05.29