without triggering a forced reflow.
Step 2: Batch All Reads Before Any Writes
The browser marks the layout as "dirty" the moment a style property is mutated. If you read a layout property after a write, the browser must immediately recalculate geometry to return an accurate value. Batching all reads at the top of the effect, followed by all writes at the bottom, ensures the layout is calculated exactly once.
Step 3: Defer Continuous Animations to requestAnimationFrame
For animations or continuous position updates, useLayoutEffect is too synchronous and will block the paint thread. Instead, read the initial state in useLayoutEffect, then schedule subsequent updates via requestAnimationFrame. This aligns your mutations with the browser's native frame pacing, allowing the GPU to composite layers efficiently.
Implementation Example
The following TypeScript implementation demonstrates a performant floating panel that tracks a trigger element. It uses a custom hook to encapsulate the read/write batching and rAF scheduling pattern.
import { useRef, useLayoutEffect, useEffect, useState, useCallback } from 'react';
interface Position {
top: number;
left: number;
}
interface UseFloatingPanelOptions {
offset?: number;
enabled: boolean;
}
export function useFloatingPanel({ offset = 12, enabled }: UseFloatingPanelOptions) {
const triggerRef = useRef<HTMLElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const rafIdRef = useRef<number | null>(null);
const [position, setPosition] = useState<Position>({ top: 0, left: 0 });
// Synchronous pre-paint adjustment for initial placement
useLayoutEffect(() => {
if (!enabled || !triggerRef.current || !panelRef.current) return;
const trigger = triggerRef.current;
const panel = panelRef.current;
// BATCHED READS: Layout is clean here
const triggerRect = trigger.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
// BATCHED WRITES: Apply immediately before paint
const nextTop = triggerRect.bottom + offset;
const nextLeft = triggerRect.left + (triggerRect.width - panelRect.width) / 2;
panel.style.transform = `translate(${nextLeft}px, ${nextTop}px)`;
setPosition({ top: nextTop, left: nextLeft });
}, [enabled, offset]);
// Continuous tracking via rAF (only when needed)
const trackPosition = useCallback(() => {
if (!triggerRef.current || !panelRef.current) return;
const trigger = triggerRef.current;
const panel = panelRef.current;
const triggerRect = trigger.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const nextTop = triggerRect.bottom + offset;
const nextLeft = triggerRect.left + (triggerRect.width - panelRect.width) / 2;
panel.style.transform = `translate(${nextTop}px, ${nextLeft}px)`;
setPosition({ top: nextTop, left: nextLeft });
}, [offset]);
useEffect(() => {
if (!enabled) return;
const startTracking = () => {
rafIdRef.current = requestAnimationFrame(trackPosition);
};
window.addEventListener('scroll', startTracking, { passive: true });
window.addEventListener('resize', startTracking, { passive: true });
return () => {
window.removeEventListener('scroll', startTracking);
window.removeEventListener('resize', startTracking);
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, [enabled, trackPosition]);
return { triggerRef, panelRef, position };
}
Architecture Rationale
useLayoutEffect for Initial Placement: Ensures the panel appears at the correct coordinates on the first paint, eliminating the "flash of mispositioned content."
- Read/Write Batching:
getBoundingClientRect() calls are grouped before any style.transform assignments. This prevents the browser from recalculating layout mid-execution.
requestAnimationFrame for Tracking: Scroll and resize events fire unpredictably. Deferring updates to rAF guarantees they run once per frame, preventing redundant calculations and aligning with the browser's composite phase.
transform over top/left: CSS transforms are GPU-composited and do not trigger layout or paint, only composite. This drastically reduces frame cost during continuous updates.
Pitfall Guide
1. Interleaved Reads and Writes
Explanation: Alternating between reading layout properties (offsetWidth, getBoundingClientRect()) and writing styles in the same execution block forces the browser to recalculate geometry after every write. This is layout thrashing.
Fix: Collect all measurements first. Store them in local variables. Apply all style mutations afterward.
2. Overusing useLayoutEffect for Non-Visual Logic
Explanation: useLayoutEffect blocks the browser's paint thread. Using it for data fetching, analytics, or non-visual state updates delays the first paint and increases Time to Interactive (TTI).
Fix: Reserve useLayoutEffect exclusively for DOM measurements and pre-paint style adjustments. Use useEffect for everything else.
3. Forgetting requestAnimationFrame Cleanup
Explanation: Unmounted components that leave rAF callbacks scheduled continue executing, causing memory leaks and attempts to mutate detached DOM nodes.
Fix: Always store the rAF ID in a ref and call cancelAnimationFrame() in the cleanup function of useEffect.
4. Assuming useEffect Prevents Visual Flicker
Explanation: useEffect runs asynchronously after the browser has already painted the frame. Any positional adjustments made here will cause a visible jump on the next frame.
Fix: Use useLayoutEffect for any logic that affects the initial visual appearance of an element.
5. Blocking the Main Thread with Heavy Calculations
Explanation: Performing complex math, string parsing, or large array operations inside useLayoutEffect extends the synchronous block, pushing frame duration past 16.6ms.
Fix: Pre-calculate values during render, use Web Workers for heavy computation, or defer non-critical math to requestIdleCallback.
6. Ignoring GPU-Composited Properties
Explanation: Mutating width, height, top, or left triggers layout and paint. Even with correct timing, these properties are expensive to animate continuously.
Fix: Prefer transform and opacity for animations. They only trigger the composite phase, which is handled by the GPU and rarely causes jank.
Explanation: Calling requestAnimationFrame within useLayoutEffect defers the callback to the next frame because the current frame's pipeline is already locked. This introduces a one-frame delay and potential flicker.
Fix: Apply immediate positional fixes directly in useLayoutEffect. Reserve rAF for continuous or deferred updates where a single frame delay is acceptable.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Initial tooltip placement | useLayoutEffect | Runs pre-paint; eliminates FOUC without blocking subsequent frames | Low (single synchronous block) |
| Drag-and-drop tracking | requestAnimationFrame | Aligns with browser frame pacing; prevents redundant event handler calls | Medium (GPU-composited transform) |
| Scroll-linked parallax | requestAnimationFrame + passive listener | Decouples scroll events from main thread; batches updates per frame | Low-Medium |
| Data fetching on mount | useEffect | Async execution; does not block paint or layout calculations | Zero |
| Continuous counter animation | requestAnimationFrame | Guarantees 60/120fps sync; avoids setInterval drift | Low |
| Measuring dynamic list height | useLayoutEffect with batching | Ensures accurate measurement before paint; prevents layout thrashing | Low |
Configuration Template
// hooks/useSyncedPosition.ts
import { useRef, useLayoutEffect, useEffect, useCallback } from 'react';
export function useSyncedPosition(
targetRef: React.RefObject<HTMLElement>,
containerRef: React.RefObject<HTMLElement>,
options: { offset: number; enabled: boolean }
) {
const rafId = useRef<number | null>(null);
const updatePosition = useCallback(() => {
if (!targetRef.current || !containerRef.current) return;
const target = targetRef.current;
const container = containerRef.current;
const targetRect = target.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const x = targetRect.left + targetRect.width / 2 - containerRect.width / 2;
const y = targetRect.bottom + options.offset;
container.style.transform = `translate3d(${x}px, ${y}px, 0)`;
}, [targetRef, containerRef, options.offset]);
useLayoutEffect(() => {
if (!options.enabled) return;
updatePosition();
}, [options.enabled, updatePosition]);
useEffect(() => {
if (!options.enabled) return;
const scheduleUpdate = () => {
rafId.current = requestAnimationFrame(updatePosition);
};
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
return () => {
window.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
if (rafId.current !== null) cancelAnimationFrame(rafId.current);
};
}, [options.enabled, updatePosition]);
return { updatePosition };
}
Quick Start Guide
- Install Dependencies: Ensure your project uses React 18+ and TypeScript. No additional packages are required.
- Create Refs: Attach
useRef to your trigger element and your floating container.
- Initialize the Hook: Import
useSyncedPosition and pass the refs along with { offset: 12, enabled: true }.
- Apply CSS: Set the container to
position: fixed or absolute, and ensure transform is the only animated property.
- Verify Performance: Open Chrome DevTools → Performance → Record. Interact with the component. Confirm layout markers are absent and frame duration stays green.