A free VIN decode widget can throw: bad JSON from a proxy, an unexpected null in a field mapper, a chart library that chokes on an empty DriveType, or a child that assumes Results[0] always exists. If that throw escapes to the root, React unmounts the whole tree and the user sees a blank shell -- or your app-level fallback that loses the search form, history, and navigation.
Error boundaries let you isolate the decode card. The page stays up; only the widget shows a recovery UI.
What boundaries catch
Class-based (or library) error boundaries catch render-phase errors, lifecycle errors, and constructors in their child tree. They do not catch:
- Event handler errors (wrap those in try/catch)
- Async errors in
useEffectpromises (handle in the effect or a query library) - Server-side errors outside the React tree
- Errors in the boundary itself
So a boundary is necessary but not sufficient. Pair it with safe data guards and explicit async error state.
Minimal boundary
import React from "react";
type Props = {
children: React.ReactNode;
resetKey?: string;
fallback?: React.ReactNode;
};
type State = { error: Error | null };
export class VinWidgetBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error("VIN widget crashed", error, info.componentStack);
}
componentDidUpdate(prev: Props): void {
if (prev.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null });
}
}
render(): React.ReactNode {
if (this.state.error) {
return (
this.props.fallback ?? (
<div role="alert">
<p>VIN details could not be rendered.</p>
<p>Try another VIN or refresh this card -- the rest of the page still works.</p>
</div>
)
);
}
return this.props.children;
}
}
resetKey matters. When the user submits a new VIN, bump the key so a prior crash does not stick forever.
Wire it around the card, not the app
export function VinLookupPage(): React.ReactElement {
const [vin, setVin] = React.useState("");
const [submitted, setSubmitted] = React.useState<string | null>(null);
return (
<main>
<h1>VIN lookup</h1>
<form
onSubmit={(e) => {
e.preventDefault();
setSubmitted(vin.trim().toUpperCase());
}}
>
<label htmlFor="vin">VIN</label>
<input id="vin" value={vin} onChange={(e) => setVin(e.target.value)} />
<button type="submit">Decode</button>
</form>
{submitted ? (
<VinWidgetBoundary resetKey={submitted}>
<VinDecodeCard vin={submitted} />
</VinWidgetBoundary>
) : null}
</main>
);
}
Keep the form outside the boundary. If the card throws, the user can still edit the VIN and submit again.
Defend the mapper too
Boundaries are a backstop. Prefer not throwing when vPIC omits a field:
export function pickResult(
payload: { Results?: Array<Record<string, string>> } | null,
): Record<string, string> | null {
const row = payload?.Results?.[0];
if (!row || typeof row !== "object") return null;
return row;
}
export function VinDecodeCard({ vin }: { vin: string }): React.ReactElement {
const [data, setData] = React.useState<Record<string, string> | null>(null);
const [err, setErr] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
setErr(null);
setData(null);
fetch(`/api/decode?vin=${encodeURIComponent(vin)}`)
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((json) => {
if (cancelled) return;
const row = pickResult(json);
if (!row) {
setErr("No decode row returned");
return;
}
setData(row);
})
.catch((e: unknown) => {
if (cancelled) return;
setErr(e instanceof Error ? e.message : "Decode failed");
});
return () => {
cancelled = true;
};
}, [vin]);
if (err) return <p role="alert">{err}</p>;
if (!data) return <p>Decoding...</p>;
return (
<dl>
<dt>Make</dt>
<dd>{data.Make || "Not provided"}</dd>
<dt>Model</dt>
<dd>{data.Model || "Not provided"}</dd>
</dl>
);
}
Async failures land in err state -- the boundary never sees them. The boundary still saves you when a child chart or third-party badge throws during render.
Logging without burying the user
In componentDidCatch, send a structured event: VIN length (not the full VIN if you treat it as sensitive in logs), component stack, and boundary name. Do not replace the alert with a silent empty div. Empty recovery is how "blank page" comes back as "blank card" and users assume the product is broken.
Testing the boundary without blanking CI
Mount the page, force a child to throw once, and assert the form still exists in the document. A useful regression is: after a throw, change resetKey and confirm the fallback clears.
function Boom({ fire }: { fire: boolean }): React.ReactElement {
if (fire) throw new Error("render boom");
return <p>ok</p>;
}
// Pseudocode with your test renderer:
// render(<VinWidgetBoundary resetKey="A"><Boom fire /></VinWidgetBoundary>);
// expect(screen.getByRole("alert")).toBeTruthy();
// rerender(<VinWidgetBoundary resetKey="B"><Boom fire={false} /></VinWidgetBoundary>);
// expect(screen.getByText("ok")).toBeTruthy();
Also assert that an intentional fetch failure path shows the inline role="alert" from state -- not the boundary fallback -- so you do not confuse network errors with render crashes in support tickets.
Takeaway
Wrap the VIN decode card in an error boundary with a resetKey tied to the submitted VIN, keep the form outside, and handle fetch errors in state. The page stays usable when one widget misbehaves -- which is the bar for a trustworthy free lookup UI.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)