Pitfall
useLayoutEffect는 성능을 저하시킬 수 있음. 가능하면useEffect를 사용하는 것이 좋음.
브라우저가 화면을 다시 그리기 전에 실행되는 useEffect의 한 버전
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의 로직이 포함된 함수. Setup 함수는 선택적으로 'cleanup' 함수를 반환할 수도 있음. 컴포넌트가 DOM에 추가되기 전에 React는 setup 함수를 실행함. 변경된 dependencies로 다시 렌더링할 때마다 React는 먼저 이전 값으로 cleanup 함수(제공한 경우)를 실행한 다음 새 값으로 setup 함수를 실행함. 컴포넌트가 DOM에서 제거되기 전에 React는 cleanup 함수를 실행함.
dependencies (optional): setup 코드 내에서 참조된 모든 반응형 값의 목록. 반응형 값에는 props, state, 컴포넌트 본문에서 직접 선언된 모든 변수와 함수가 포함됨. Linter가 React에 대해 구성된 경우, 모든 반응형 값이 dependency로 올바르게 지정되었는지 확인함. Dependencies 목록에는 일정한 수의 항목이 있어야 하며 [dep1, dep2, dep3]와 같이 인라인으로 작성해야함. React는 Object.is 비교를 사용하여 각 dependency를 이전 값과 비교함. 이 인수를 생략하면 컴포넌트를 다시 렌더링할 때마다 Effect가 다시 실행됨.
undefined를 반환함.
useLayoutEffect는 Hook이므로 컴포넌트의 최상위 레벨 또는 자체 Hook에서만 호출할 수 있음. 루프나 조건 안에서는 호출할 수 없음. 필요하다면 컴포넌트를 추출하고 Effect를 그곳으로 이동할 것.
Strict Mode가 켜져 있으면 React는 첫 번째 실제 setup 전에 개발 전용 setup + cleanup 사이클을 한 번 더 실행함. 이는 cleanup 로직이 setup 로직을 "미러링"하고 setup이 수행 중인 모든 작업을 중지하거나 취소하는지 확인하는 스트레스 테스트임. 문제가 발생하면 cleanup 기능을 구현할 것.
Dependencies 중 일부가 컴포넌트 내부에 정의된 객체 또는 함수인 경우, 이로 인해 Effect가 필요 이상으로 자주 다시 실행될 위험이 있음. 이 문제를 해결하려면 불필요한 객체 및 함수 dependency를 제거할 것. 또한 state 업데이트와 비반응성 로직을 Effect 외부로 추출할 수도 있음.
Effect는 클라이언트에서만 실행됨. 서버 렌더링 중에는 실행되지 않음.
useLayoutEffect 내부의 코드와 여기서 예약된 모든 state 업데이트는 브라우저가 화면을 다시 그리는 것을 차단하므로 과도하게 사용하면 앱이 느려짐. 가능하면 useEffect를 사용하는 것이 좋음.
대부분의 컴포넌트는 무엇을 렌더링할지 결정하기 위해 화면에서 자신의 위치와 크기를 알 필요 없이, 그저 JSX만 반환함. 그러면 브라우저는 레이아웃(위치 및 크기)을 계산하고 화면을 다시 그림.
때로는 이것만으로는 충분하지 않음. Hover 시 어떤 요소 옆에 표시되는 tooltip을 상상해보면, 공간이 충분하다면 tooltip이 요소 위에 표시되어야 하지만, 공간이 충분하지 않다면 아래에 표시되어야 함. Tooltip을 올바른 최종 위치에 렌더링하려면 tooltip의 높이(즉, 요소 위의 공간에 배치될 수 있는지)를 알아야 함.
이를 위해서는 두 번의 패스로 렌더링해야 함:
이 모든 작업은 브라우저가 화면을 다시 그리기 전에 이루어져야 함. 사용자에게 tooltip이 움직이는 모습을 보여서는 안됨. 브라우저가 화면을 다시 그리기 전에 useLayoutEffect를 호출하여 레이아웃 측정을 수행할 것:
function Tooltip() {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0); // You don't know real height yet
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(height); // Re-render now that you know the real height
}, []);
// ...use tooltipHeight in the rendering logic below...
}
단계별 작동 방식은 다음과 같음:
Tooltip은 초기 tooltipHeight = 0으로 렌더링됨(따라서 툴팁의 위치가 잘못될 수 있음).
React는 tooltip을 DOM에 배치하고 useLayoutEffect의 코드를 실행함.
useLayoutEffect는 tooltip 콘텐츠의 높이를 측정하고 즉시 다시 렌더링을 트리거함.
Tooltip은 실제 tooltipHeight로 다시 렌더링됨(따라서 tooltip의 위치가 올바르게 지정됨).
React는 DOM에서 이를 업데이트하고 브라우저는 마침내 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
);
}
Tooltip 컴포넌트가 두 번에 걸쳐(먼저, 0으로 초기화 된 tooltipHeight로, 다음은 실제 측정된 높이로) 렌더링되어야 함에도 불구하고 최종 결과만 표시된다는 점에 유의할 것.
Note
두 번에 걸쳐 렌더링하고 브라우저를 blocking하면 성능이 저하됨. 가능한 한 이를 피할 것.
useLayoutEffect의 목적은 컴포넌트가 렌더링에 레이아웃 정보를 사용할 수 있도록 하는 것:
사용자 또는 프레임워크가 서버 렌더링을 사용하는 경우, React 앱은 초기 렌더링을 할 때 서버의 HTML에 렌더링함. 이를 통해 JavaScript 코드가 로드되기 전에 초기 HTML을 표시할 수 있음.
문제는 서버에 레이아웃 정보가 없다는 것!
앞선 예제에서 Tooltip 컴포넌트의 useLayoutEffect 호출은 콘텐츠 높이에 따라 콘텐츠 위 또는 아래에 올바르게 배치될 수 있도록 함. 초기 서버 HTML의 일부로 Tooltip을 렌더링하려고 했다면 이를 결정할 수 없었을 것. 서버에는 아직 레이아웃이 없기 때문! 따라서 서버에서 렌더링하더라도 JavaScript가 로드되고 실행된 후 클라이언트에서 그 위치가 '점프'됨.
일반적으로 레이아웃 정보에 의존하는 컴포넌트는 서버에서 렌더링할 필요가 없음. 예를 들어, 초기 렌더링 중에 Tooltip을 표시하는 것은 의미가 없을 수 있음. Tooltip은 클라이언트 상호작용에 의해 트리거됨.
그러나 이 문제를 해결할 몇 가지 다른 방법이 있음:
useLayoutEffect대신 useEffect를 사용할 것. 이렇게 하면 React가 페인트를 막지 않고 초기 렌더링 결과를 표시해도 괜찮다는 것을 알 수 있음(Effect가 실행되기 전에 원래 HTML이 표시되기 때문).
또는 컴포넌트를 클라이언트 전용으로 표시할 것. 이렇게 하면 서버 렌더링 중에 가장 가까운 <Suspense> 바운더리까지의 콘텐츠를 loading fallback(예: spinner 또는 glimmer)으로 대체하도록 React에 지시할 수 있음.
또는 hydration 후에 useLayoutEffect를 사용하여 컴포넌트를 렌더링할 수 있음. false로 초기화된 boolean isMounted state를 유지하고, useEffect 호출 내에서 true로 설정한 후 렌더링 로직은 return isMounted ? <RealContent /> : <FallbackContent />처럼 작성할 것. 서버에서와 hydration 중에 사용자는 useLayoutEffect를 호출해서는 안 되는 FallbackContent를 보게 됨. 그런 다음 React는 이를 클라이언트에서만 실행되고useLayoutEffect 호출을 포함할 수 있는 RealContent로 대체함.
컴포넌트를 외부 데이터 저장소와 동기화하고 레이아웃 측정이 아닌 다른 이유로 useLayoutEffect에 의존하는 경우, 서버 렌더링을 지원하는 useSyncExternalStore를 대신 고려할 것.