The report was that the dashboard was blank. Not broken, not showing an error — blank, with a working navigation bar above nothing. The cause was a null in a widget in the corner that three people used, and the consequence was that nobody could see anything.
The symptom
TypeError: Cannot read property 'toFixed' of null
at RevenueWidget (RevenueWidget.js:24)
The above error occurred in the <RevenueWidget> component:
in RevenueWidget
in div
in Dashboard
Consider adding an error boundary to your tree to customize error
handling behavior.The message names the fix, which is unusually helpful, and the behaviour it is explaining is deliberate: React 16 unmounts the entire tree when an error is not caught. That is a change from 15, where the component was left in a broken state and the page limped on.
Why it happens
A component that threw in 15 left React with an inconsistent internal tree, and subsequent renders could produce corrupted output — the wrong data in the wrong component, silently. The team’s position in 16 is that a blank page is better than wrong data, and that is defensible for a banking dashboard and irritating for a widget nobody reads.
The mechanism they added alongside it is the point: unmounting is the default because it is safe, and a boundary lets you choose something better for the part of the tree where you know what better means.
The fix
Error boundaries, and where to put them
class ErrorBoundary extends React.Component {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true }; // render phase, no side effects
}
componentDidCatch(error, info) {
reportToSentry(error, {
componentStack: info.componentStack,
boundary: this.props.name,
});
}
render() {
return this.state.failed
? (this.props.fallback || <WidgetFailed />)
: this.props.children;
}
}
The two methods do different jobs and both are needed: getDerivedStateFromError is called during the render phase and may not have side effects, and componentDidCatch is called during commit and is where the reporting goes. Putting the reporting in the first one works today and is documented as unsupported, which is the kind of thing that stops working in a minor release.
Placement is the design decision. One boundary at the root turns a blank page into a slightly friendlier blank page. A boundary per independent region means a failing widget shows a small apology and the rest of the dashboard works, which is what anybody actually wants.
<Dashboard>
<ErrorBoundary name="revenue"><RevenueWidget /></ErrorBoundary>
<ErrorBoundary name="orders"><OrdersWidget /></ErrorBoundary>
<ErrorBoundary name="stock"><StockWidget /></ErrorBoundary>
</Dashboard>
Warning
Boundaries catch errors during rendering, in lifecycle methods and in constructors below them. They do not catch errors in event handlers, in asynchronous code, in server rendering, or in the boundary itself. A fetch rejection in a click handler is still an unhandled rejection and needs its own try.
The new context API, and the prop drilling it replaces
16.3 replaced the legacy context with one that is supported, and it removes the pattern where a value is passed down six components that do not use it.
const CurrencyContext = React.createContext('GBP');
// provider, once, near the root
<CurrencyContext.Provider value={user.currency}><Dashboard /></CurrencyContext.Provider>
// consumer, at any depth
<CurrencyContext.Consumer>
{currency => <Price amount={total} currency={currency} />}
</CurrencyContext.Consumer>
// or, in a class, without the render prop
static contextType = CurrencyContext;
The value is compared by reference, so passing an object literal in the provider re-renders every consumer on every parent render — the single most common performance mistake with this API, and it is invisible until somebody profiles. Hoisting the value into state or memoising it is the fix.
This is not a state manager and treating it as one produces an application where every state change re-renders everything. It is for values that are genuinely ambient — theme, locale, currency, the current user — and rarely change.
React.lazy and Suspense, for code splitting
const Reports = React.lazy(() => import('./Reports'));
function App() {
return (
<ErrorBoundary name="reports">
<React.Suspense fallback={<Spinner />}>
<Reports />
</React.Suspense>
</ErrorBoundary>
);
}
The dynamic import() is what webpack turns into a separate chunk, so the reports code is downloaded when somebody navigates to reports rather than on first load. On that dashboard it moved 180 kilobytes out of the initial bundle, which is most of the initial bundle.
The error boundary around it is not optional and is left out of most examples. A chunk that fails to load — a deploy that removed the old file while a user had the page open, which happens on every release — rejects the import, and without a boundary that is a blank page for the reason this article started with.
This is client-side only in 16.6; server rendering with Suspense does not work yet, so an application using renderToString needs a different splitting library. Knowing that before adopting it saves a day.
The lifecycle methods that are going away
// deprecated in 16.3, UNSAFE_ prefix, removed in 17:
// componentWillMount, componentWillReceiveProps, componentWillUpdate
static getDerivedStateFromProps(props, state) {
if (props.customerId !== state.lastCustomerId) {
return { data: null, lastCustomerId: props.customerId };
}
return null; // no state change
}
The deprecations exist because those methods can be called more than once for a single commit under the asynchronous rendering that 16 was built for, and code with side effects in them breaks in ways that are extremely hard to reproduce. The codemod React ships renames them mechanically, which buys a version and does not fix anything.
Most uses of componentWillReceiveProps were fetching data when a prop changed, and the honest replacement for that is componentDidUpdate rather than the derived-state method — deriving state from props is usually a sign that the state should not exist.
Verifying it worked
# a component that throws on purpose, behind a query flag
$ open 'http://localhost:3000/dashboard?boom=revenue'
# the revenue widget shows the fallback.
# every other widget renders.
# the error is in Sentry with a component stack.
$ npm run build
File sizes after gzip:
142.11 KB build/static/js/main.chunk.js # was 322 KB
84.02 KB build/static/js/2.chunk.js
11.40 KB build/static/js/reports.chunk.js
$ npx jest --testPathPattern ErrorBoundary
PASS src/ErrorBoundary.test.js (4 tests)A deliberate throw behind a query parameter is worth keeping in the codebase permanently. It makes the boundaries testable by hand in any environment, and it is the only way to find out that the fallback for one of them was never styled and renders as unformatted text.
The failure to check is the reporting: a boundary that catches an error and does not report it converts a visible outage into an invisible one, which is worse. Confirming the Sentry event arrives with a component stack is the assertion that matters more than the fallback rendering.
What this costs
Boundaries have to be class components — there is no hook for them — which is awkward in a codebase moving toward function components, and it will stay awkward for a while. In practice that means one utility class written once and used everywhere, which is a small enough cost that the awkwardness is more aesthetic than real.
The larger risk is that boundaries make failures quiet. A dashboard where three widgets have been showing an apology for two weeks looks fine to everybody except the three people who use them, and there is no page-level signal at all. Alerting on boundary catches — not just recording them — is what keeps the mechanism from becoming a way of hiding bugs, and it is the step that gets skipped because the page looks fine.