This page is part of the Fallback UI Rendering Patterns cluster, which covers how error boundaries surface failure states without degrading perceived performance.
The exact failure scenario #
A dashboard widget powered by a third-party data feed throws during render. React’s error boundary catches it and swaps the widget subtree with a <SkeletonCard> fallback. The outer grid does not change, the sidebar does not move — but the browser still records a Cumulative Layout Shift score of 0.14, killing the page’s Core Web Vital. The cause: the fallback’s intrinsic height differs from the crashed widget’s rendered height, and no containment policy tells the layout engine to ignore that difference.
The fix is a two-part contract: lock dimensions before the error propagates and constrain geometry so the change never escapes the boundary subtree.
Zero-to-working implementation #
The snippet below is a complete, self-contained solution for React 18+. It works for class-based and functional boundary wrappers.
// useDimensionLock.ts — capture pre-crash geometry and hold it through fallback mount
import { useLayoutEffect, useRef, useState } from 'react';
interface LockedSize { width: number; height: number }
/**
* Returns a ref to attach to the boundary wrapper and the last-known locked size.
* Call captureSize() from the error boundary's getDerivedStateFromError path (via a
* callback prop) or, for hooks-based boundaries, in the layout effect before error state flips.
*/
export function useDimensionLock() {
const wrapperRef = useRef<HTMLDivElement>(null);
const [lockedSize, setLockedSize] = useState<LockedSize | null>(null);
// Runs synchronously after paint — guarantees a real pixel measurement
useLayoutEffect(() => {
const el = wrapperRef.current;
if (!el) return;
// Snapshot geometry before any error can alter it
const { width, height } = el.getBoundingClientRect();
setLockedSize({ width, height });
// Re-lock on resize so stale dimensions don't persist across viewport changes
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const { inlineSize: w, blockSize: h } = entry.contentBoxSize[0];
setLockedSize({ width: w, height: h });
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return { wrapperRef, lockedSize };
}
// ContainedBoundary.tsx — error boundary wrapper that enforces the no-shift contract
import React, { Component, ReactNode } from 'react';
import { useDimensionLock } from './useDimensionLock';
interface Props {
fallback: ReactNode;
children: ReactNode;
}
interface State { hasError: boolean }
// Inner functional component owns the dimension lock; class boundary owns error state
function BoundaryShell({ fallback, children }: Props) {
const { wrapperRef, lockedSize } = useDimensionLock();
const [hasError, setHasError] = React.useState(false);
const containerStyle: React.CSSProperties = {
contain: 'layout size paint',
isolation: 'isolate',
// Locked values prevent outer layout recalculation when fallback mounts
...(lockedSize
? { width: lockedSize.width, height: lockedSize.height, overflow: 'auto' }
: { minHeight: '4rem' }),
};
return (
<div ref={wrapperRef} style={containerStyle} data-boundary-shell="true">
{hasError ? fallback : (
<ErrorTrigger onError={() => setHasError(true)}>
{children}
</ErrorTrigger>
)}
</div>
);
}
// Thin class wrapper — only class components can implement componentDidCatch
class ErrorTrigger extends Component<{
children: ReactNode;
onError: () => void;
}, { caught: boolean }> {
state = { caught: false };
static getDerivedStateFromError() { return { caught: true }; }
componentDidCatch(_: Error, info: React.ErrorInfo) {
// Trigger dimension lock swap in the parent shell before next paint
this.props.onError();
console.debug('[ContainedBoundary] error caught', info.componentStack);
}
render() {
return this.state.caught ? null : this.props.children;
}
}
export { BoundaryShell as ContainedBoundary };
Step-by-step explanation #
-
useDimensionLockcaptures geometry synchronously.useLayoutEffectfires after DOM mutation but before the browser paints.getBoundingClientRect()returns the rendered pixel values, not the CSS declarations, so it is accurate even when the parent uses flexbox or grid fractional sizing. -
ResizeObserverkeeps the lock fresh. Without this, a viewport resize after mount would leave stale dimensions.contentBoxSize[0]is used instead ofgetBoundingClientRect()inside the observer callback because it fires before the next frame’s paint, avoiding a forced reflow. -
contain: layout size paintis the critical declaration.layoutprevents the subtree from affecting siblings;sizetells the engine the element’s dimensions are self-contained;paintclips overflow without triggering a stacking context on ancestors. All three together mean swapping children never generates a layout-shift entry. -
isolation: isolateprevents stacking-context bleed. Without it,z-indexorfilterchanges inside the fallback can repaint ancestor layers, which some browsers record as paint cost that indirectly correlates with shift scoring. -
overflow: autoon the locked container. If the fallback is taller than the crashed component the content scrolls internally rather than expanding the wrapper and pushing siblings. -
onErrorcallback bridges the class/hooks boundary. The classErrorTriggercatches synchronously viagetDerivedStateFromError, then callsonError()to flip state in the parent shell. This is the only reliable way to useuseLayoutEffect-based locks alongside a class error boundary in React 18 without resorting to a full class conversion.
Anatomy of the no-shift swap #
Edge cases #
| Scenario | Risk | Mitigation |
|---|---|---|
| SSR / React Server Components | useLayoutEffect is a no-op on the server; the wrapper renders without locked dimensions, causing a brief client-side measurement gap. |
Server-render the wrapper with aspect-ratio matching the expected content box and patch pixel values in useLayoutEffect immediately after hydration. |
| Nested error boundaries sharing a container | Two sibling boundaries both applying contain: size can compete and produce different effective sizes, triggering a recalculation on the shared ancestor. |
Wrap sibling boundaries in a grid cell with contain: layout so each cell is independent. |
| Dynamic font loading affecting fallback text | A custom font that loads after the fallback mounts can cause a text-block reflow inside the locked container and, on some browsers, a small shift if the container underflows. | Add font-display: optional for fallback-only fonts, or preload the font file with <link rel="preload" as="font">. |
| Viewport resize invalidating locked dimensions | A window resize fires during the fallback window, and the stale locked size no longer matches the surrounding grid. | The ResizeObserver in useDimensionLock re-measures and updates; ensure the locked size state update is batched with flushSync only if strict CLS control is needed. |
Verification steps #
Chrome DevTools — Layout Shift attribution:
- Open DevTools → Performance tab → record a page load.
- In the summary pane filter for “Layout Shift” entries.
- Click each entry: the “Sources” panel names the shifted element. Entries attributed to
[data-boundary-shell]indicate the containment is not applied or the lock fired too late.
Lighthouse CLI:
npx lighthouse http://localhost:3000/your-page \
--only-categories=performance \
--output=json \
| jq '.audits["cumulative-layout-shift"].numericValue'
Target: <= 0.05 (Good) or 0.0 if the boundary is the only interactive region on the page.
Playwright CLS assertion:
import { test, expect } from '@playwright/test';
test('error boundary fallback causes zero CLS', async ({ page }) => {
await page.goto('/dashboard');
// Inject a deterministic crash into the target widget
await page.evaluate(() => {
(window as any).__injectBoundaryError?.('data-widget-id', 'price-chart');
});
// Collect layout-shift entries via PerformanceObserver
const cls = await page.evaluate((): Promise<number> =>
new Promise((resolve) => {
let score = 0;
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
score += (entry as PerformanceEntry & { value: number }).value;
}
});
obs.observe({ type: 'layout-shift', buffered: true });
// Allow two animation frames for the boundary swap to complete
requestAnimationFrame(() => requestAnimationFrame(() => resolve(score)));
})
);
expect(cls).toBeLessThanOrEqual(0.05);
});
Console log pattern (quick sanity check in dev):
// Add to your boundary's componentDidCatch or onError callback during development
performance.getEntriesByType('layout-shift').forEach((e) => {
const shift = e as PerformanceEntry & { value: number; sources?: { node?: Node }[] };
if (shift.value > 0) {
console.warn('[CLS] shift detected after boundary swap', shift.value, shift.sources);
}
});
Frequently Asked Questions #
Why does my fallback still shift even though I set a fixed height?
Fixed height alone does not stop the layout engine from recalculating when the wrapper lacks CSS containment. Adding contain: layout size paint prevents geometry from escaping the subtree regardless of the fallback’s intrinsic size. Height alone constrains the element but does not prevent ancestor reflow.
Does this technique work with SSR / React Server Components?
The dimension-locking hook runs only on the client — in useLayoutEffect — so the server renders the wrapper without inline dimensions. To avoid a hydration mismatch, server-render the wrapper with the same contain and aspect-ratio CSS but without pixel values; the hook patches them immediately after hydration before the browser paints.
How do I handle a fallback that is taller than the original component?
Allow overflow: auto on the locked container rather than overflow: hidden. The layout footprint stays constant; the fallback scrolls internally rather than pushing sibling elements down.
Related #
- Fallback UI Rendering Patterns — the parent cluster covering skeleton states, progressive disclosure, and multi-level fallback hierarchies
- Component Isolation Techniques — CSS containment strategies and shadow DOM scoping for boundary subtrees
- Frontend Error Boundary Architecture Fundamentals — the foundational model for boundary placement, propagation scope, and recovery coordination
- State Reset and Cleanup Protocols — safely disposing refs, subscriptions, and closure state when a boundary activates