⚠️ 주의
useLayoutEffect는 성능에 악영향을 줄 수 있어요. 가능하면useEffect를 사용하는 걸 권장합니다.
useLayoutEffect는 브라우저가 화면을 다시 그리기(repaint) 전에 실행되는 버전의 useEffect예요.
useLayoutEffect(setup, dependencies?)
useLayoutEffect(setup, dependencies?) {/useinsertioneffect/}브라우저가 화면을 다시 그리기 전에 레이아웃 측정을 수행하려면 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 함수를 실행할 거예요. 의존성이 변경된 상태로 커밋이 일어날 때마다, React는 먼저 이전 값으로 클린업 함수를 실행하고(클린업 함수를 제공했다면), 그다음에 새로운 값으로 setup 함수를 실행해요. 컴포넌트가 DOM에서 제거되기 전에, React는 클린업 함수를 실행합니다.
선택적 dependencies: setup 코드 안에서 참조하는 모든 반응형 값(reactive values)의 목록이에요. 반응형 값에는 props, state, 그리고 컴포넌트 본문 안에서 직접 선언된 모든 변수와 함수가 포함돼요. 만약 린터가 React용으로 설정되어 있다면, 모든 반응형 값이 의존성으로 올바르게 지정되었는지 확인해 줄 거예요. 의존성 목록은 항목 수가 일정해야 하고, [dep1, dep2, dep3]처럼 인라인으로 작성해야 해요. React는 각 의존성을 Object.is 비교를 사용해서 이전 값과 비교해요. 이 인수를 생략하면, 컴포넌트가 커밋될 때마다 Effect가 다시 실행됩니다.
useLayoutEffect는 undefined를 반환해요.
useLayoutEffect는 Hook이기 때문에, 컴포넌트의 최상위 레벨 또는 커스텀 Hook에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 만약 그렇게 해야 한다면, 컴포넌트를 분리해서 Effect를 그쪽으로 옮기세요.
Strict Mode가 켜져 있으면, React는 첫 번째 실제 setup 전에 개발 환경에서만 추가로 한 번 더 setup+cleanup 사이클을 실행해요. 이건 클린업 로직이 setup 로직을 제대로 "미러링"하는지, setup이 하는 작업을 중단하거나 되돌리는지 확인하는 스트레스 테스트예요. 이게 문제를 일으킨다면, 클린업 함수를 구현하세요.
의존성 중 일부가 컴포넌트 안에서 정의된 객체나 함수라면, Effect가 필요 이상으로 자주 다시 실행될 위험이 있어요. 이걸 고치려면, 불필요한 객체와 함수 의존성을 제거하세요. 또한 Effect 바깥으로 state 업데이트와 비반응형 로직을 추출할 수도 있어요.
Effect는 클라이언트에서만 실행돼요. 서버 렌더링 중에는 실행되지 않아요.
useLayoutEffect 안의 코드와 그 안에서 예약된 모든 state 업데이트는 브라우저가 화면을 다시 그리는 것을 차단해요. 과도하게 사용하면 앱이 느려질 수 있어요. 가능하면 useEffect를 사용하는 걸 권장해요.
useLayoutEffect 안에서 state 업데이트를 트리거하면, React는 useEffect를 포함한 나머지 모든 Effect를 즉시 실행해요.
대부분의 컴포넌트는 무엇을 렌더링할지 결정하기 위해 화면에서의 위치나 크기를 알 필요가 없어요. 그냥 JSX를 반환하면 돼요. 그러면 브라우저가 레이아웃(위치와 크기)을 계산하고 화면을 다시 그리죠.
하지만 때로는 그것만으로는 부족할 때가 있어요. 마우스를 올리면(hover) 어떤 요소 옆에 나타나는 툴팁을 상상해 보세요. 공간이 충분하면 툴팁은 요소 위에 나타나야 하지만, 공간이 부족하면 아래에 나타나야 해요. 툴팁을 올바른 최종 위치에 렌더링하려면, 툴팁의 높이를 알아야 해요 (즉, 위쪽에 들어갈 수 있는지 확인해야 해요).
이걸 하려면, 두 번에 걸쳐 렌더링해야 해요:
이 모든 것이 브라우저가 화면을 다시 그리기 전에 일어나야 해요. 사용자가 툴팁이 이동하는 걸 보면 안 되니까요. 브라우저가 화면을 다시 그리기 전에 레이아웃 측정을 수행하려면 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으로 렌더링돼요 (그래서 툴팁이 잘못된 위치에 있을 수 있어요).useLayoutEffect 안의 코드를 실행해요.useLayoutEffect가 툴팁 콘텐츠의 높이를 측정하고 즉시 리렌더링을 트리거해요.Tooltip이 실제 tooltipHeight로 다시 렌더링돼요 (그래서 툴팁이 올바른 위치에 놓여요).아래 버튼들 위에 마우스를 올려보면, 툴팁이 공간에 따라 위치를 어떻게 조정하는지 확인할 수 있어요:
// 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>
);
}
// src/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>
)
}
</>
);
}
// src/Tooltip.js (active)
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
);
}
// src/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 {
color: white;
background: #222;
border-radius: 4px;
padding: 4px;
}
Tooltip 컴포넌트가 두 번에 걸쳐 렌더링해야 하지만 (처음에는 tooltipHeight가 0으로 초기화된 상태로, 그다음에는 실제 측정된 높이로), 여러분은 최종 결과만 보게 된다는 점에 주목하세요. 이 예제에서 useEffect 대신 useLayoutEffect가 필요한 이유가 바로 이거예요. 아래에서 차이점을 자세히 살펴볼게요.
useLayoutEffect는 브라우저의 다시 그리기를 차단해요 {/uselayouteffect-blocks-the-browser-from-repainting/}React는 useLayoutEffect 안의 코드와 그 안에서 예약된 모든 state 업데이트가 브라우저가 화면을 다시 그리기 전에 처리되도록 보장해요. 이렇게 하면 툴팁을 렌더링하고, 측정하고, 다시 렌더링하는 과정에서 사용자가 첫 번째 추가 렌더링을 눈치채지 못하게 할 수 있어요. 다시 말해서, useLayoutEffect는 브라우저의 페인팅을 차단해요.
// 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>
);
}
// src/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>
)
}
</>
);
}
// src/Tooltip.js (active)
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);
}, []);
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
);
}
// src/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 {
color: white;
background: #222;
border-radius: 4px;
padding: 4px;
}
useEffect는 브라우저를 차단하지 않아요 {/useeffect-does-not-block-the-browser/}여기 같은 예제인데, useLayoutEffect 대신 useEffect를 사용한 버전이에요. 느린 장치에서는 가끔 툴팁이 "깜빡이면서(flicker)" 잠깐 동안 초기 위치가 보인 다음 올바른 위치로 이동하는 걸 볼 수 있을 거예요.
// 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>
);
}
// src/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>
)
}
</>
);
}
// src/Tooltip.js (active)
import { useRef, useEffect, 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);
useEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(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
);
}
// src/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 {
color: white;
background: #222;
border-radius: 4px;
padding: 4px;
}
버그를 더 쉽게 재현하기 위해, 이 버전에서는 렌더링 중에 인위적인 지연을 추가했어요. React는 useEffect 안의 state 업데이트를 처리하기 전에 브라우저가 화면을 그리도록 허용해요. 그 결과, 툴팁이 깜빡여요:
// 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>
);
}
// src/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>
)
}
</>
);
}
// src/Tooltip.js (active)
import { useRef, useEffect, 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);
// This artificially slows down rendering
let now = performance.now();
while (performance.now() - now < 100) {
// Do nothing for a bit...
}
useEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTooltipHeight(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
);
}
// src/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 {
color: white;
background: #222;
border-radius: 4px;
padding: 4px;
}
이 예제를 useLayoutEffect로 수정해서 렌더링이 느려지더라도 페인팅을 차단하는지 관찰해 보세요.
참고
두 번에 걸쳐 렌더링하고 브라우저를 차단하는 건 성능에 악영향을 줘요. 가능하면 피하도록 하세요.
useLayoutEffect does nothing on the server" 에러가 나요 {/im-getting-an-error-uselayouteffect-does-nothing-on-the-server/}useLayoutEffect의 목적은 컴포넌트가 렌더링에 레이아웃 정보를 활용하도록 하는 거예요:
여러분이나 여러분의 프레임워크가 서버 렌더링을 사용하면, React 앱이 초기 렌더링 시 서버에서 HTML로 렌더링돼요. 이렇게 하면 JavaScript 코드가 로드되기 전에 초기 HTML을 보여줄 수 있죠.
문제는 서버에는 레이아웃 정보가 없다는 거예요.
앞서의 예제에서, Tooltip 컴포넌트의 useLayoutEffect 호출은 콘텐츠 높이에 따라 올바른 위치에 (콘텐츠 위 또는 아래) 배치되도록 해줘요. 만약 Tooltip을 초기 서버 HTML의 일부로 렌더링하려고 하면, 이것을 결정할 수 없어요. 서버에는 아직 레이아웃이 없으니까요! 그래서 서버에서 렌더링하더라도, JavaScript가 로드되고 실행된 후에 클라이언트에서 위치가 "점프"하게 될 거예요.
보통 레이아웃 정보에 의존하는 컴포넌트는 어차피 서버에서 렌더링할 필요가 없어요. 예를 들어, 초기 렌더링 중에 Tooltip을 보여주는 건 아마 의미가 없을 거예요. 그건 클라이언트 인터랙션에 의해 트리거되니까요.
하지만 이 문제에 부딪혔다면, 몇 가지 다른 옵션이 있어요:
useLayoutEffect를 useEffect로 대체하세요. 이렇게 하면 React에게 페인팅을 차단하지 않고 초기 렌더링 결과를 표시해도 괜찮다고 알려주는 거예요 (원본 HTML이 Effect가 실행되기 전에 보이게 되니까요).
또는, 컴포넌트를 클라이언트 전용으로 표시하세요. 이렇게 하면 React에게 서버 렌더링 중에 해당 콘텐츠를 가장 가까운 <Suspense> 경계까지 로딩 폴백(예를 들어 스피너나 글리머)으로 대체하라고 알려주는 거예요.
또는, 하이드레이션(hydration) 후에만 useLayoutEffect가 있는 컴포넌트를 렌더링할 수도 있어요. false로 초기화된 boolean isMounted state를 유지하고, useEffect 호출 안에서 true로 설정하세요. 그러면 렌더링 로직은 return isMounted ? <RealContent /> : <FallbackContent />처럼 될 수 있어요. 서버와 하이드레이션 중에는 사용자가 FallbackContent를 보게 되는데, 이건 useLayoutEffect를 호출하지 않아야 해요. 그러면 React가 이것을 클라이언트에서만 실행되는 RealContent로 대체하고, 이 안에서 useLayoutEffect 호출을 포함할 수 있어요.
외부 데이터 저장소와 컴포넌트를 동기화하면서 레이아웃 측정이 아닌 다른 이유로 useLayoutEffect에 의존하고 있다면, 서버 렌더링을 지원하는 useSyncExternalStore를 대신 고려해 보세요.