DEV Community

Vin Lookup
Vin Lookup

Posted on

React Error Boundaries Around VIN Decode Widgets Without Blanking the Page

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 useEffect promises (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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)