typed-fetch

React Hooks

useTypedFetch, useTypedMutation, and TypedFetchProvider for React 18+

React Hooks

typed-fetch ships first-class React hooks powered by useSyncExternalStore (React 18+). Components re-render automatically whenever the cache updates — no polling, no manual subscriptions.

# React is an optional peer dependency
pnpm add react@^18

Import from @phumudzo/typed-fetch/react. React is an optional peer dependency — nothing from this subpath is imported unless you use it.

Setup

Create a cache and wrap your app with TypedFetchProvider:

// main.tsx
import { createTypedFetchCache } from "@phumudzo/typed-fetch";
import { TypedFetchProvider } from "@phumudzo/typed-fetch/react";

const cache = createTypedFetchCache({
  staleTime: 60_000,  // 60 s
  gcTime: 5 * 60_000, // 5 min
});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <TypedFetchProvider cache={cache}>
      <App />
    </TypedFetchProvider>
  </StrictMode>,
);

useTypedFetch

Fetches data, caches the result, and re-renders when it changes.

import { useTypedFetch } from "@phumudzo/typed-fetch/react";

function UserCard({ id }: { id: number }) {
  const { result, isLoading, isError, error, refetch } = useTypedFetch(
    `/api/users/${id}`,
    undefined,
    { endpointKey: "GET /api/users/:id" },
  );

  if (isLoading) return <p>Loading…</p>;
  if (isError)   return <p>Network error: {error?.error.message}</p>;
  if (result?.status === 200) return <h1>{result.data.name}</h1>;
  if (result?.status === 404) return <p>User not found</p>;
  return null;
}

Options

OptionTypeDefaultDescription
endpointKeyTypedEndpointKeyrequiredEndpoint pattern. Autocompletes from all known keys.
enabledbooleantrueWhen false the fetch is skipped. Use for dependent queries.
refetchIntervalnumber | falsePoll interval in ms.
keepPreviousDatabooleanfalseReturn the last key's data while the new key loads. Ideal for pagination.
initialDataTypedFetchResult<K>Seed the cache before the first render — no network call if provided.
select(result) => TSelectedTransform or pick a subset from the result.
onSuccess(result) => voidCalled after every non-network-error response (including 4xx).
onError(error) => voidCalled on network error (status === 0).
onSettled(result) => voidCalled after every completed fetch.
refetchOnWindowFocusbooleantrueRefetch stale entries when the tab regains focus.
refetchOnReconnectbooleantrueRefetch when the browser comes back online.
cacheTypedFetchCachectx cacheOverride the Provider cache for this hook.
configPartial<TypedFetchConfig>typed-fetch config overrides.

Return values

PropertyTypeDescription
resultTSelected | undefinedCached result, or select() return value. undefined while loading.
isLoadingbooleantrue when no result exists and fetch is enabled.
isFetchingbooleantrue while any request is in-flight for this key.
isSuccessbooleantrue when result.ok === true (2xx status).
isErrorbooleantrue on network error (status === 0).
errorTypedFetchNetworkError<K> | undefinedFull network error object when isError is true.
refetch() => voidInvalidate + immediately re-fetch.
invalidate() => voidRemove this URL from cache (no auto re-fetch).
invalidateEndpoint() => voidRemove all cached URLs for this endpoint key.

Dependent queries

Use enabled to wait for a required value before fetching:

function PostComments({ postId }: { postId: number | null }) {
  const { result, isLoading } = useTypedFetch(
    `/api/posts/${postId}/comments`,
    undefined,
    {
      endpointKey: "GET /api/posts/:id/comments",
      enabled: postId !== null, // skip until postId is known
    },
  );

  if (!postId)    return <p>Select a post first.</p>;
  if (isLoading)  return <p>Loading comments…</p>;
  if (result?.status === 200) {
    return <ul>{result.data.map(c => <li key={c.id}>{c.body}</li>)}</ul>;
  }
  return null;
}

Pagination with keepPreviousData

function PostsList() {
  const [page, setPage] = useState(1);

  const { result, isFetching } = useTypedFetch(
    `/api/posts?page=${page}`,
    undefined,
    {
      endpointKey: "GET /api/posts",
      keepPreviousData: true, // show previous page while next page loads
    },
  );

  return (
    <div>
      {isFetching && <span>Updating…</span>}
      {result?.status === 200 &&
        result.data.map((p) => <article key={p.id}>{p.title}</article>)}
      <button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>Prev</button>
      <button onClick={() => setPage((p) => p + 1)}>Next</button>
    </div>
  );
}

Polling

const { result } = useTypedFetch("/api/status", undefined, {
  endpointKey: "GET /api/status",
  refetchInterval: 5_000, // poll every 5 s
});

select — transform the result

const { result: userName } = useTypedFetch(
  `/api/users/${id}`,
  undefined,
  {
    endpointKey: "GET /api/users/:id",
    select: (r) => (r.status === 200 ? r.data.name : undefined),
  },
);
// result is string | undefined — no status discrimination needed

Invalidation from a hook

function UserCard({ id }: { id: number }) {
  const { result, refetch, invalidate, invalidateEndpoint } = useTypedFetch(
    `/api/users/${id}`,
    undefined,
    { endpointKey: "GET /api/users/:id" },
  );

  return (
    <div>
      {result?.status === 200 && <p>{result.data.name}</p>}
      <button onClick={refetch}>Refresh</button>
      <button onClick={invalidate}>Clear this user</button>
      <button onClick={invalidateEndpoint}>Clear all users</button>
    </div>
  );
}

useTypedMutation

Manages a single mutation (POST, PUT, PATCH, DELETE). Mutations always hit the network — no caching.

import { useTypedMutation } from "@phumudzo/typed-fetch/react";

function CreateUserForm() {
  const { mutate, isLoading, isSuccess, isError, error, result, reset } =
    useTypedMutation({
      endpointKey: "POST /api/users",
      onSuccess: () => {
        // Invalidate the user list so it refetches
        cache.invalidateByEndpoint("GET /api/users");
      },
      onError: (err) => console.error(err.error.message),
    });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
    mutate("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: fd.get("name") }),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <button disabled={isLoading}>
        {isLoading ? "Saving…" : "Create"}
      </button>
      {isError  && <p>Error: {error?.error.message}</p>}
      {isSuccess && result?.status === 201 && (
        <p>Created user #{result.data.id}</p>
      )}
      {(isSuccess || isError) && (
        <button type="button" onClick={reset}>Reset</button>
      )}
    </form>
  );
}

Options

OptionTypeDescription
endpointKeyTypedEndpointKeyrequired
onSuccess(result) => voidCalled after non-network-error response.
onError(error) => voidCalled on network error (status === 0).
onSettled(result) => voidCalled after every mutation.
configPartial<TypedFetchConfig>typed-fetch config overrides.

Return values

PropertyTypeDescription
mutate(url, init?) => voidFire and forget.
mutateAsync(url, init?) => Promise<result>Fire and await.
resultTypedFetchResult<K> | undefinedLatest result.
isLoadingbooleanIn-flight.
isSuccessbooleanok === true (2xx).
isErrorbooleanNetwork error.
errorTypedFetchNetworkError<K> | undefinedNetwork error details.
reset() => voidClear result and flags.

mutateAsync — await the result

const { mutateAsync } = useTypedMutation({ endpointKey: "POST /api/checkout" });

async function checkout() {
  const result = await mutateAsync("/api/checkout", {
    method: "POST",
    body: JSON.stringify(cart),
  });
  if (result.status === 200) {
    navigate(`/orders/${result.data.orderId}`);
  }
}

TypedFetchProvider

Provides a TypedFetchCache to all hooks in the subtree via React context.

import { TypedFetchProvider } from "@phumudzo/typed-fetch/react";

<TypedFetchProvider cache={cache}>
  <App />
</TypedFetchProvider>

Every useTypedFetch and useTypedMutation in the tree reads the cache from context. You can override it per-hook with options.cache.


Next: Adapters

Learn how to use server-side adapters to observe responses from Hono, Next.js, or any other framework.

On this page