Type a generic fetch hook that returns a known payload

The problem

You want one hook that can request any JSON endpoint and hand the caller a value whose type the compiler trusts. The project runs TypeScript in strict mode against React 19 hooks, the endpoint shape is documented rather than guaranteed, and response.json() gives you an unchecked value that is only known at runtime.

Short answer

Declare the state as a discriminated union over a type parameter, require the caller to pass a type predicate, build the success variant only after that predicate returns true, and abort the request in the effect cleanup.

A fetch hook is only useful if the value it returns is trustworthy. This one is generic over the payload type and requires the caller to bring a type predicate, so the hook can state in its signature that success data has passed a runtime check rather than a cast.

Language: TypeScript
// src/use-fetch.ts
import { useEffect, useState } from "react";

export type FetchState<TPayload> =
  | { readonly status: "idle" }
  | { readonly status: "loading" }
  | { readonly status: "success"; readonly data: TPayload }
  | { readonly status: "error"; readonly error: Error };

export type UseFetchResult<TPayload> = {
  readonly state: FetchState<TPayload>;
  readonly reload: () => void;
};

export function useFetch<TPayload>(
  url: string,
  narrowResponse: (value: unknown) => value is TPayload,
): UseFetchResult<TPayload> {
  const [state, setState] = useState<FetchState<TPayload>>({ status: "idle" });
  const [revision, setRevision] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading" });

    void (async (): Promise<void> => {
      try {
        const response = await fetch(url, { signal: controller.signal });

        if (!response.ok) {
          throw new Error(`Request failed with status ${response.status}`);
        }

        const payload: unknown = await response.json();

        if (!narrowResponse(payload)) {
          throw new Error("Response body did not match the expected shape");
        }

        setState({ status: "success", data: payload });
      } catch (error) {
        if (controller.signal.aborted) {
          return;
        }

        setState({
          status: "error",
          error: error instanceof Error ? error : new Error(String(error)),
        });
      }
    })();

    return () => {
      controller.abort();
    };
  }, [url, narrowResponse, revision]);

  return {
    state,
    reload: () => setRevision((current) => current + 1),
  };
}

The consuming component never writes a type assertion. It picks a payload type, hands over the matching predicate, and reads state.data only in the branch the discriminated union leaves available.

Language: TSX
// src/release-banner.tsx
import { useFetch } from "./use-fetch";

const releasesUrl = "https://api.example.com/releases";

function isVersionList(value: unknown): value is readonly string[] {
  return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string");
}

export function ReleaseBanner() {
  const { state, reload } = useFetch(releasesUrl, isVersionList);

  if (state.status === "idle" || state.status === "loading") {
    return <p>Loading release notes…</p>;
  }

  if (state.status === "error") {
    return (
      <p>
        {state.error.message}
        <button type="button" onClick={reload}>
          Try again
        </button>
      </p>
    );
  }

  return (
    <ul>
      {state.data.map((version) => (
        <li key={version}>{version}</li>
      ))}
    </ul>
  );
}

Explanation

The hook keeps the payload type as a parameter rather than hard-coding a response shape, and FetchState is a discriminated union: every variant carries a literal status, so a check on that property is enough for the compiler to decide which other properties exist. useState is annotated with the same union, which means a later setState call cannot mix variants — { status: "success" } without data is rejected at compile time. The generic then flows back out through UseFetchResult, so a component that asks for readonly string[] gets state.data typed as readonly string[] and cannot accidentally treat it as something else.

The interesting part is where that type comes from. response.json() is declared to return a promise of an unchecked value, so assigning it to a TPayload variable would be a guess. This hook instead takes (value: unknown) => value is TPayload and calls it before building the success variant. The predicate is what makes the assignment honest: inside the type system a type predicate is the one construct that says “a runtime check happened here”, and because the parsed body is a const in the same block, the compiler narrows it after the guard, so the data property receives a value already known to be TPayload. TypeScript erases generics when it emits JavaScript, so nothing about the type parameter survives into the bundle; if you delete the predicate argument and cast the body instead, the code still compiles and only the runtime data tells you whether the shape was real.

Two boundary conditions are worth testing against your own endpoints. First, the effect lists narrowResponse in its dependencies, which is correct because the effect calls it — pass a module-level function or a useCallback result, otherwise identity changes each render, the effect re-runs, and the request repeats. Second, the response.ok check runs before the body is parsed, so a 500 with a JSON error object surfaces as an HTTP failure rather than as a narrowing failure; only a 2xx response reaches the predicate. Aborted requests are swallowed by the controller.signal.aborted guard in the catch block, which is what stops a cancelled navigation from being reported as an error to the reader.

Parameters

Named inputs for the code above
NameTypeRequiredDefaultDescription
urlstringYes—Absolute HTTPS endpoint requested on mount and on every dependency change.
narrowResponse(value: unknown) => value is TPayloadYes—Caller-supplied type predicate that decides whether the parsed body may be treated as TPayload.

Expected output

state is idle or loading before a response arrives, success carries the narrowed payload, error carries an Error, and reload starts the request again.

Usage notes

  • Pass a predicate declared at module scope, or one held in useCallback, because an inline arrow function changes identity on every render and restarts the request.
  • Keep retries, caching, and authorisation headers out of this hook until an endpoint's semantics actually require them.

Common mistakes

  • Casting the parsed body to the payload type: the call compiles, and the component then renders fields that the server never sent.

Caveats

  • TypeScript erases the type parameter at runtime, so the predicate argument is the only thing that separates a known payload from an unchecked value.
  • Changing url aborts the previous request, which is intended but doubles network traffic if a parent rebuilds the url string on every render.

Alternatives

The untyped equivalent is less code and finishes the job for a single call site; this hook pays for a generic signature and a predicate argument only once a second screen reuses the same request shape.

Prerequisite

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References