Building the API Dashboard: Typed Client, Debounced Search & Cancellation

Implement a typed API client with runtime validation, a debounced search hook that cancels stale requests with AbortController, the loading/error/empty UI state pattern, and a simple in-memory cache.

Any dashboard that talks to a real API has to handle a cluster of concerns that don't show up in a toy fetch() call: the response shape might not match what TypeScript promises unless you validate it at runtime, a fast typist can fire off ten requests before the first one resolves and you must not render results for an outdated query, and every fetch needs to represent 'still loading', 'failed', and 'succeeded but empty' as distinct, renderable states. This lesson builds those pieces directly: a typed apiClient with a runtime type guard, a useDebouncedSearch hook that combines debouncing with AbortController cancellation, the loading/error/empty state pattern components render against, and a minimal cache so repeated identical queries don't re-hit the network.

A typed client is not a safe client — add runtime validation

TypeScript's type system disappears at runtime. Writing const data = (await res.json()) as Repo[] compiles cleanly, but if the API changes a field name or returns an error payload shaped differently, that as cast lies to the rest of the app with total confidence — the bug won't surface until something downstream crashes on undefined. The fix is a small runtime type guard that actually inspects the parsed JSON before it's trusted.

This doesn't require a validation library for a project of this scope — a hand-written predicate function that checks the fields you actually depend on is enough, and it's the same technique libraries like zod automate at scale. The key habit is: never trust fetch response shape without checking it once, at the boundary.

• apiClient.searchRepos(query, signal) — one function, one responsibility: build the URL, fetch, validate, throw a typed error on failure. • A dedicated ApiError class distinguishes 'the network/API failed' from 'the data came back malformed' from 'the request was cancelled' — each needs different handling upstream. • Validation happens once, at the client boundary — everything above it (hooks, components) can trust the Repo[] type completely.

Debouncing plus AbortController: the two halves of good search UX

Debouncing alone (waiting ~300ms after the user stops typing before firing a request) cuts down request volume, but it doesn't solve the harder problem: network latency is unpredictable, so an earlier request can still resolve *after* a later one if the earlier query happened to take longer. Without cancellation, the UI can flash the results for a stale, already-superseded query.

AbortController solves this directly: every new search creates a fresh controller, calls .abort() on the previous one, and passes the new controller's signal into fetch. An aborted fetch rejects with a distinguishable AbortError, which the hook catches and silently ignores (it's not a real failure — it's an intentionally cancelled, obsolete request), rather than treating it as an API error the user needs to see.

The loading / error / empty / success state pattern

A single boolean isLoading flag is not enough to render a dashboard correctly, because 'not loading' is ambiguous between three very different UI states: an error occurred, the search succeeded but matched nothing, or results are ready to show. Modeling these as a single discriminated union (rather than three separate booleans that could contradict each other, like isLoading && hasError both true) makes illegal states unrepresentable and makes the rendering switch exhaustive and easy to verify.

Every dashboard screen should render exactly one of: a loading skeleton/spinner, an error message with retry, an empty-state message ('no results for that query'), or the actual data. Structuring state this way means a component can never accidentally show stale data underneath a loading spinner, which is a common bug with ad hoc boolean flags.

A minimal cache layer

Repeated identical searches (a user retyping something they already searched, or navigating back to a previous query) don't need to hit the network again. A simple Map<string, Repo[]> keyed by the normalized query string, checked before every fetch, avoids redundant requests without needing a full caching library — this is the concept the spec calls for, and it generalizes directly to tools like React Query later.

The cache should live outside component state (e.g. module-level or in a ref) so it survives re-renders and persists across search sessions within the same page load, but it intentionally does *not* persist across full page reloads — that's a deliberate scope boundary distinguishing a request cache from the Task Manager's localStorage persistence.

The API client contract and error type

Field names in the guard (full_name, stargazers_count, html_url) intentionally match the raw API's snake_case shape — mapping to the app's camelCase Repo happens right after validation, in one place. ApiError carries an optional cause so the original fetch/parse error is never discarded, which matters when debugging a production issue from logs.

// api/client.ts
export interface Repo {
  id: number;
  name: string;
  fullName: string;
  description: string | null;
  stars: number;
  url: string;
}

export class ApiError extends Error {
  constructor(message: string, public readonly cause?: unknown) {
    super(message);
    this.name = "ApiError";
  }
}

// A runtime guard — the only place the raw, untyped JSON is trusted.
function isRepo(value: unknown): value is Repo {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v.id === "number" &&
    typeof v.name === "string" &&
    typeof v.full_name === "string" &&
    (v.description === null || typeof v.description === "string") &&
    typeof v.stargazers_count === "number" &&
    typeof v.html_url === "string"
  );
}

Code Examples

The AbortError from a cancelled fetch is deliberately re-thrown unmodified (not wrapped in ApiError) so the calling hook can tell 'this was cancelled on purpose' apart from 'this genuinely failed' and react differently to each. Filtering with the type guard rather than trusting the whole array means one malformed item from the API can never propagate a Repo with undefined fields into components that assume they're always present.

const validated = items.filter(isRawRepo);

Common mistake: Casting the parsed JSON directly (payload as Repo[]) skips validation entirely — it compiles fine and fails silently in production the first time the API's shape drifts.

The debounce timer and the AbortController solve two different problems: the timer stops a request from firing on every keystroke, while the controller guarantees that even if a request does fire, an older one in flight can never overwrite the result of a newer one. Storing the controller in a ref (not state) is deliberate — updating it must never trigger a re-render; it exists purely as an imperative handle to cancel the previous request.

Common mistake: Forgetting to check err.name === 'AbortError' and instead setting {status:'error'} for every catch means every keystroke after the first would flash an error message before the debounce timer even fires.

Because state is a discriminated union, the switch is exhaustive — TypeScript will flag it if a new SearchState variant is added later and this component doesn't handle it, preventing an unhandled UI case from shipping. Expected result: Exactly one of a hint, spinner, error, empty message, or results list is ever rendered — never two at once.

import { ApiError, type Repo } from "./types";

const BASE_URL = "https://api.github.com/search/repositories";

interface RawGithubRepo {
  id: number;
  name: string;
  full_name: string;
  description: string | null;
  stargazers_count: number;
  html_url: string;
}

function isRawRepo(value: unknown): value is RawGithubRepo {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v.id === "number" &&
    typeof v.name === "string" &&
    typeof v.full_name === "string" &&
    (v.description === null || typeof v.description === "string") &&
    typeof v.stargazers_count === "number" &&
    typeof v.html_url === "string"
  );
}

function toRepo(raw: RawGithubRepo): Repo {
  return {
    id: raw.id,
    name: raw.name,
    fullName: raw.full_name,
    description: raw.description,
    stars: raw.stargazers_count,
    url: raw.html_url,
  };
}

export async function searchRepos(query: string, signal: AbortSignal): Promise<Repo[]> {
  const url = `${BASE_URL}?q=${encodeURIComponent(query)}&per_page=20`;

  let res: Response;
  try {
    res = await fetch(url, { signal });
  } catch (err) {
    if (err insta
…
import { useEffect, useRef, useState } from "react";
import { searchRepos } from "../api/client";
import type { Repo } from "../api/types";

type SearchState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "empty" }
  | { status: "success"; data: Repo[] };

const cache = new Map<string, Repo[]>();
const DEBOUNCE_MS = 300;

export function useDebouncedSearch(query: string) {
  const [state, setState] = useState<SearchState>({ status: "idle" });
  const controllerRef = useRef<AbortController | null>(null);

  useEffect(() => {
    const trimmed = query.trim();

    if (!trimmed) {
      controllerRef.current?.abort();
      setState({ status: "idle" });
      return;
    }

    const cached = cache.get(trimmed);
    if (cached) {
      setState(cached.length === 0 ? { status: "empty" } : { status: "success", data: cached });
      return;
    }

    const timer = window.setTimeout(async () => {
      controllerRef.current?.abort(); // cancel any in-flight request for a previous query
      const controller = new AbortController();
      controllerRef.current = controller;

      setState({ status: "loading" });
      try {
   
…
import { useDebouncedSearch } from "../hooks/useDebouncedSearch";

interface SearchResultsProps {
  query: string;
}

export function SearchResults({ query }: SearchResultsProps) {
  const state = useDebouncedSearch(query);

  switch (state.status) {
    case "idle":
      return <p className="hint">Start typing to search repositories.</p>;
    case "loading":
      return <p role="status" aria-live="polite">Searching…</p>;
    case "error":
      return (
        <p role="alert" className="error">
          Something went wrong: {state.message}
        </p>
      );
    case "empty":
      return <p className="empty-state">No repositories matched "{query}".</p>;
    case "success":
      return (
        <ul>
          {state.data.map((repo) => (
            <li key={repo.id}>
              <a href={repo.url} target="_blank" rel="noreferrer">
                {repo.fullName}
              </a>{" "}
              ★ {repo.stars}
              {repo.description && <p>{repo.description}</p>}
            </li>
          ))}
        </ul>
      );
  }
}

Search request lifecycle: debounce → cache check → fetch → cancel

Only the most recent debounced query ever gets to update visible state; every earlier in-flight request is cancelled or ignored.

keystroke ──► query state changes
        │
        ▼
   useEffect(query) fires
        │
        ├─ query empty? ─────────────► status: idle
        │
        ├─ cache.get(query) hit? ────► status: success/empty (no network)
        │
        └─ else: setTimeout(300ms)
               │  (retyping before 300ms clears this timer — no request sent)
               ▼
        abort() previous AbortController
               │
               ▼
        new AbortController + fetch(url, {signal})
               │
        ┌──────┴───────┐
        ▼               ▼
   success/empty     AbortError → ignored (superseded)
   → cache.set()      real error → status: error

Important Rules

Never trust as Type on parsed JSON — validate the shape you actually depend on with a runtime type guard before treating data as typed.

Pair debouncing (fewer requests) with AbortController cancellation (correct final state) — debouncing alone does not prevent a slow, stale request from overwriting a fresh result.

Model loading/error/empty/success as one discriminated union, not independent booleans, so the render logic is exhaustive and mutually exclusive states can't coexist.

Distinguish an aborted request (expected, ignore it) from a genuine failure (show the user an error) by checking err.name === 'AbortError' in the catch block.

Where this pattern shows up beyond dashboards

This exact trio — typed client with runtime validation, debounce+cancellation, and a discriminated-union state machine — is the manual version of what libraries like React Query or SWR automate (they add caching, retries, and cancellation out of the box). Learning to build it by hand here means you understand precisely what those libraries are doing under the hood when you adopt them on a larger project, instead of treating them as a black box.

const data = (await res.json()) as Repo[]; setResults(data);

const payload = await res.json(); const validated = items.filter(isRawRepo).map(toRepo); setResults(validated);

The unchecked cast compiles but provides zero runtime safety — if the API ever returns an error object or a differently-shaped item, the app renders undefined fields instead of failing loudly at the boundary.

useEffect(() => { fetch(url).then(r => r.json()).then(setResults); }, [query]);

useEffect(() => { const controller = new AbortController(); fetch(url, {signal: controller.signal})...; return () => controller.abort(); }, [query]);

Without an AbortController and a cleanup function, a slow response to an old query can resolve after a newer one and overwrite its results — a classic race condition in search-as-you-type UIs.

Why is debouncing alone insufficient to prevent stale search results from being shown? A. Debouncing doesn't work in React B. Even with debouncing, network latency is unpredictable — an earlier request can still resolve after a later one, so without cancellation the UI can display results for an already-superseded query C. Debouncing only works with GET requests D. Debouncing prevents all requests from ever completing

B. Even with debouncing, network latency is unpredictable — an earlier request can still resolve after a later one, so without cancellation the UI can display results for an already-superseded query — Debouncing controls how often requests are sent, not the order in which they resolve — AbortController is what guarantees a stale response can never overwrite a newer one.

In useDebouncedSearch, why is the AbortController stored in a ref rather than in state? A. Refs are required for all objects in TypeScript B. Because updating a ref does not trigger a re-render — the controller is an imperative handle used only to cancel a request, not a value the UI renders from C. State cannot hold class instances D. It has no effect either way

B. Because updating a ref does not trigger a re-render — the controller is an imperative handle used only to cancel a request, not a value the UI renders from — A ref is the correct tool for values that need to persist across renders but whose changes shouldn't themselves cause a re-render — exactly the case for an AbortController used only for cancellation.

Why does the catch block check err.name === 'AbortError' before setting an error state? A. To satisfy the TypeScript compiler only B. AbortError is thrown for a request that was intentionally cancelled because a newer search superseded it, not a genuine failure — surfacing it as an error would flash a false error message on every keystroke C. AbortError never actually occurs in browsers D. It has no functional purpose, only stylistic

B. AbortError is thrown for a request that was intentionally cancelled because a newer search superseded it, not a genuine failure — surfacing it as an error would flash a false error message on every keystroke — Distinguishing cancellation from failure lets the hook silently drop obsolete requests while still surfacing real network/API errors to the user.

Extend the API Dashboard beyond what's shown: add a responsive grid layout for the results (single column on narrow viewports, multi-column on wide ones), a retry button on the error state that re-triggers the last search, and a cache eviction policy (e.g. cap the Map at 50 entries, evicting the oldest). Also add a loading skeleton component instead of the plain 'Searching…' text.

A simple LRU-style cap on the cache can be implemented by tracking insertion order: when cache.size exceeds 50 after a set, delete the first key from cache.keys() (Map preserves insertion order in JS), giving you eviction of the oldest entry without a separate library.

For retry, store the last submitted query in a ref or extra state so the error-state button can call the same search function again without requiring the user to retype anything.