Have you ever built a tooltip that snaps from the top-left corner to its correct spot? Or a modal that flashes at the wrong size before settling into place? That split-second glitch is a layout flicker. It happens when React reads the DOM, calculates a correction, and updates state, but the browser has already started pushing pixels to the screen. The usual prescription is to swap useEffect for useLayoutEffect. The swap works, but only if you understand exactly when each hook fires inside the browser pipeline.
브라우저 파이프라인: Render, Commit, Paint
React는 세 가지 뚜렷한 단계로 컴포넌트를 업데이트합니다. render 단계에서 React는 Virtual DOM을 구축(또는 재구축)하고 차이점(diff)을 계산합니다. 아직 실제 픽셀 변화는 일어나지 않으며, 이는 메모리 내에서 발생하는 순수한 계산 과정입니다. 다음은 commit 단계로, React가 해당 변경 사항을 실제 DOM 노드에 적용합니다. 스타일이 업데이트되고, 노드가 삽입되거나 제거되며, 텍스트가 변경됩니다.
그 후 브라우저가 제어권을 넘겨받습니다. paint 단계에서 브라우저의 렌더링 엔진은 레이아웃 기하학적 구조를 계산하고 화면에 픽셀을 그립니다. 이 시퀀스는 엄격합니다. 브라우저는 페인트를 하기 전에 레이아웃을 완료해야 하며, 사용자가 새로운 내용을 보기 전에 페인팅을 마쳐야 합니다. commit과 paint 사이의 간격은 밀리초 단위로 측정되지만, 분명히 존재하며, 바로 이 구간이 useEffect와 useLayoutEffect가 갈라지는 지점입니다.
useEffect가 깜빡임을 유발하는 이유
useEffect는 비동기적으로 실행되며, 브라우저가 이미 화면을 그린 후에 실행되도록 예약됩니다. DOM이 업데이트되고 픽셀이 그려진 후에야 React가 다시 개입하여 이펙트를 실행합니다.
버튼 아래에 드롭다운 메뉴를 렌더링한다고 가정해 봅시다. useEffect 내부에서 buttonRef.current.getBoundingClientRect()를 호출하여 정확한 top 및 left 좌표를 계산하고 이를 상태(state)에 저장합니다. useEffect는 페인트 후에 실행되므로, 브라우저는 이미 드롭다운을 기본 위치(예: top: 0, left: 0)에 그려 놓은 상태입니다. 그 페인트가 이루어진 후에야 이펙트가 상태를 업데이트합니다. React는 수정된 좌표를 커밋하고, 브라우저는 다시 페인트를 합니다. 사용자는 잘못된 위치가 먼저 보이고 그다음 올바른 위치가 보이는 두 개의 프레임을 보게 됩니다. 그 시각적인 툭 튀는 현상이 바로 모두가 피하려는 깜빡임입니다.
데이터 페칭, API 호출, 분석 트래킹 또는 이벤트 리스너 설정의 경우, 이러한 지연은 문제가 되지 않습니다. 사용자는 분석 비콘(analytics beacon)이 페인트 후 몇 밀리초 뒤에 실행되는지 신경 쓰지 않습니다. 사실, 시각적이지 않은 작업을 페인트 이후로 미루는 것은 초기 렌더링의 응답성을 유지하는 데 도움이 됩니다. 하지만 레이아웃에 의존적인 수정 작업의 경우, useEffect는 너무 늦습니다.
useLayoutEffect가 페인트를 차단하는 방식
useLayoutEffect는 동기적으로 실행됩니다. React가 DOM을 변경한 직후, 하지만 브라우저가 레이아웃을 계산하거나 픽셀을 페인트할 기회를 갖기 전에 실행됩니다. 이는 페인트 파이프라인을 완전히 차단합니다.
useLayoutEffect 내부에서 동일한 드롭다운 측정을 수행하면 시퀀스가 바뀝니다. React가 초기 DOM 업데이트를 커밋하고 레이아웃 이펙트를 실행하면, 상태 업데이트가 동기적 재렌더링을 트리거합니다. React가 수정된 좌표를 커밋하고, 그제서야 브라우저가 페인트를 합니다. 사용자는 이미 올바른 상태인 단 하나의 프레임만을 보게 됩니다.
이러한 차단 동작은 기능인 동시에 위험 요소이기도 합니다. useLayoutEffect는 작업이 완료될 때까지 브라우저의 페인팅을 막기 때문에, 내부에서 무거운 계산을 수행하면 UI가 얼어붙습니다. 단 몇십 밀리초의 페인트 차단만으로도 사용자는 버벅임(jank)을 느낄 수 있습니다. 이것이 React 문서에서 먼저 useEffect를 사용하고, 도저히 참을 수 없는 깜빡임이 실제로 관찰될 때만 useLayoutEffect로 전환하라고 명시적으로 권장하는 이유입니다.
각 훅을 사용하는 시점
대부분의 로직은 useEffect에 속합니다. 다음의 경우에 사용하세요:
- API에서 데이터 가져오기
- 구독(subscriptions) 또는 이벤트 리스너 설정
- 분석 이벤트 전송
- 레이아웃을 즉시 읽거나 변경하지 않는 모든 사이드 이펙트
사용자가 프레임을 보기 전에 반드시 DOM을 읽고 다시 써야 하는 작업은 useLayoutEffect를 위해 남겨두세요:
- 너비(width), 높이(height) 또는 스크롤 위치와 같은 요소의 크기 측정
- 툴팁, 팝오버 또는 컨텍스트 메뉴의 좌표 계산
- 시각적 위치가 렌더링된 기하학적 구조에 의존할 때 발생하는 가시적인 레이아웃 시프트(layout shifts) 방지
어느 것을 선택할지 확신이 서지 않는다면 기본적으로 useEffect를 사용하세요. 시각적 불안정성이 느껴질 때만 useLayoutEffect로 옮기십시오. 이 규칙 하나만으로도 대부분의 React 애플리케이션을 매끄럽게 유지할 수 있습니다.
서버 사이드 렌더링(SSR) 시 주의할 점
If you use Next.js, Remix, or any framework that renders React on the server, you will hit a warning with useLayoutEffect. Because the server has no DOM, the hook has nothing to measure. React warns you that it expected a browser environment and did not find one. During hydration, this mismatch can also cause subtle bugs because the server-rendered markup and the client’s first intended render may differ.
The standard fix is an isomorphic hook that selects the right effect based on the environment:
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
Use this wrapper in any component that must measure DOM nodes but might execute during server rendering. It silences the warning and keeps your server output consistent.
Performance and Best Practices
Since useLayoutEffect blocks painting, keep the body of the hook as light as possible. Read the layout value, compute the correction, and write it back. Do not fetch data, parse large objects, or run expensive algorithms inside it. Heavy code here will stall the main thread and make your interface feel frozen.
When you measure elements, use React refs rather than document.getElementById. Refs are tied to your component instance, survive re-renders without query tricks, and work reliably with portals or conditional rendering. Global ID lookups break component encapsulation and can return null at exactly the moment you need them.
useEffect is the right default for nearly every side effect. It lets the browser paint without interruption and handles data, events, and external synchronization cleanly. useLayoutEffect is a specialized tool for a specific problem: reading layout and writing back before the paint. Master the timing difference between them, and you will stop chasing flickers and start preventing them.
The real takeaway: Start with useEffect for everything. The moment you see a tooltip or modal blink into the wrong place before correcting itself, that is your signal. Switch to useLayoutEffect, measure the DOM, adjust your layout, and let the browser paint once—correctly.
