DEV Community

Vin Lookup
Vin Lookup

Posted on

Cancelling In-Flight VIN Decode Fetches on Navigation and Unmount

A free VIN decode often means fetch to NHTSA DecodeVinValues (or your thin proxy) from a React route. Users type, paste, hit back, open another listing, or close a drawer while the previous request is still in flight. If you ignore cancellation, a late response can paint Make/Model for VIN A onto a screen that now shows VIN B -- or setState on an unmounted tree and fight React Strict Mode twice.

This post is about cancelling in-flight decode work on navigation and unmount: AbortController ownership, React cleanup, and commit guards so zombie NHTSA responses never win.

The failure mode

Typical sequence:

  1. User submits VIN A; you fetch with no abort signal.
  2. User navigates to another vehicle page (VIN B) or leaves the decode panel.
  3. Component unmounts or effect re-runs for VIN B.
  4. VIN A response arrives; a careless .then still calls setResult.
  5. UI shows A under B's chrome, analytics count a "success" for the wrong VIN, or React warns about updates after unmount.

Timeouts alone do not fix this. A 12-second budget still leaves a window where navigation is faster than NHTSA. Single-flight coalescing helps identical keys; it does not cancel when the user left the screen.

Own one AbortController per in-flight decode

Create a controller when the decode starts. Pass signal into fetch. Abort in the effect cleanup (unmount or dependency change) and when the user starts a newer decode for a different normalized VIN.

import { useEffect, useState } from "react";

const VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/;

export function normalizeVin(raw: string): string {
  return raw.trim().toUpperCase().replace(/[\s\-._]/g, "");
}

export type DecodeRow = Record<string, string>;

export async function decodeVinValues(
  vin: string,
  signal: AbortSignal,
): Promise<DecodeRow> {
  const url =
    "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/" +
    encodeURIComponent(vin) +
    "?format=json";
  const res = await fetch(url, { signal });
  if (!res.ok) throw new Error(`vPIC HTTP ${res.status}`);
  const body = (await res.json()) as { Results?: DecodeRow[] };
  const row = body.Results?.[0];
  if (!row) throw new Error("vPIC returned no Results row");
  return row;
}

export function useVinDecode(rawVin: string) {
  const [row, setRow] = useState<DecodeRow | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const vin = normalizeVin(rawVin);
    if (!VIN_RE.test(vin)) {
      setRow(null);
      setError(null);
      setLoading(false);
      return;
    }

    const ctrl = new AbortController();
    let cancelled = false;
    setLoading(true);
    setError(null);

    decodeVinValues(vin, ctrl.signal)
      .then((next) => {
        if (cancelled || ctrl.signal.aborted) return;
        // Commit guard: response must still match the effect's VIN.
        const responseVin = normalizeVin(String(next.VIN ?? vin));
        if (responseVin !== vin) return;
        setRow(next);
        setLoading(false);
      })
      .catch((err: unknown) => {
        if (cancelled || ctrl.signal.aborted) return;
        if (err instanceof DOMException && err.name === "AbortError") return;
        setError(err instanceof Error ? err.message : "Decode failed");
        setLoading(false);
        setRow(null);
      });

    return () => {
      cancelled = true;
      ctrl.abort();
    };
  }, [rawVin]);

  return { row, error, loading };
}
Enter fullscreen mode Exit fullscreen mode

Cleanup aborts the HTTP call. The cancelled flag and AbortError swallow stop setState after unmount. Comparing the response VIN to the effect's VIN blocks cross-wiring when a shared cache returns an unexpected row.

Navigation vs timeout

Use cancellation for lifecycle (route change, unmount, new VIN). Use a separate timeout signal when you care about wall-clock patience. Compose them so either event aborts the same fetch:

export function mergeAbortSignals(
  a: AbortSignal,
  b: AbortSignal,
): AbortSignal {
  const out = new AbortController();
  const forward = () => out.abort();
  if (a.aborted || b.aborted) {
    out.abort();
    return out.signal;
  }
  a.addEventListener("abort", forward, { once: true });
  b.addEventListener("abort", forward, { once: true });
  return out.signal;
}

// Example: effect signal + 12s budget
const timeout = AbortSignal.timeout(12_000);
const signal = mergeAbortSignals(ctrl.signal, timeout);
Enter fullscreen mode Exit fullscreen mode

Do not treat timeout AbortError as "invalid VIN." Do not treat navigation abort as a user-visible failure toast if the panel is already gone -- silent cleanup is correct.

SPA routers and layout persistence

In Next.js App Router, Remix, or React Router, a parent layout may stay mounted while a child route swaps. Put the decode effect in the component that owns the VIN param so cleanup runs when the param changes -- not only on full page exit. If decode lives in a shared shell, key the hook (or remount boundary) on the normalized VIN so React tears down the previous effect.

Avoid module-level mutable "latest request id" alone without abort: ids prevent painting wrong data but still waste NHTSA quota and keep sockets busy. Prefer abort and a generation counter if multiple callers share one client.

What not to cancel

If several waiters share one upstream decode (single-flight), aborting when the first waiter unmounts can starve the others. Reference-count the controller: abort only when the last subscriber leaves. Interactive one-shot UI decode (one panel, one VIN) should abort eagerly on cleanup.

Never abort by closing a global singleton controller that other pages still need. Scope controllers to the effect or to a request-scoped client.

Small tests that lock behavior

import assert from "node:assert/strict";

export function shouldCommitDecode(args: {
  aborted: boolean;
  effectVin: string;
  responseVin: string;
}): boolean {
  if (args.aborted) return false;
  return normalizeVin(args.effectVin) === normalizeVin(args.responseVin);
}

assert.equal(
  shouldCommitDecode({
    aborted: true,
    effectVin: "1HGCM82633A004352",
    responseVin: "1HGCM82633A004352",
  }),
  false,
);
assert.equal(
  shouldCommitDecode({
    aborted: false,
    effectVin: "1HGCM82633A004352",
    responseVin: "1FTFW1E50MKE12345",
  }),
  false,
);
assert.equal(
  shouldCommitDecode({
    aborted: false,
    effectVin: "1HGCM82633A004352",
    responseVin: "1HGCM82633A004352",
  }),
  true,
);
Enter fullscreen mode Exit fullscreen mode

In component tests, assert that unmount calls abort on the controller you inject, and that a resolve after abort does not change rendered Make/Model.

Takeaway

In-flight VIN decode fetches must die with the screen that started them. Own an AbortController per effect, abort on unmount and VIN change, ignore AbortError in UI copy when the user already left, and commit only when the response still matches the active VIN. Your free lookup stays honest in the network panel and calm in React -- no ghost vehicles after navigation.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)