typed-fetch

React Integration

Using typed-fetch hooks in React applications

React Integration

Complete examples of using typed-fetch hooks in React applications.

For full API reference, see React Hooks. This page focuses on practical, real-world patterns.

Setup

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

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

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

Basic data fetching

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

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

  if (isLoading) return <div>Loading…</div>;
  if (isError)   return <div>Network error: {error?.error.message}</div>;

  if (result?.status === 200) {
    return (
      <div>
        <h1>{result.data.name}</h1>
        <p>{result.data.email}</p>
      </div>
    );
  }

  if (result?.status === 404) return <div>User not found</div>;
  return <div>Error: {result?.status}</div>;
}

Refetch and invalidation

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

  return (
    <article>
      {isFetching && <span className="badge">Updating…</span>}
      {result?.status === 200 && (
        <>
          <h2>{result.data.title}</h2>
          <p>{result.data.body}</p>
        </>
      )}
      <footer>
        <button onClick={refetch}>Refresh this post</button>
        <button onClick={invalidate}>Clear from cache</button>
        <button onClick={invalidateEndpoint}>Clear all posts</button>
      </footer>
    </article>
  );
}

Dependent queries

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

  if (!postId) return <p>Select a post to see 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}&limit=10`,
    undefined,
    {
      endpointKey: "GET /api/posts",
      keepPreviousData: true, // keep showing page N while page N+1 loads
    },
  );

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

Polling

function StatusBanner() {
  const { result } = useTypedFetch("/api/status", undefined, {
    endpointKey: "GET /api/status",
    refetchInterval: 10_000, // refetch every 10 seconds
  });

  return (
    <div>
      {result?.status === 200
        ? `System: ${result.data.status}`
        : "Checking status…"}
    </div>
  );
}

select — transform the result

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

  if (isLoading) return <span>…</span>;
  return <span>{name ?? "Unknown"}</span>;
}

POST with useTypedMutation

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

function CreateUserForm({ cache }: { cache: TypedFetchCache }) {
  const { mutate, isLoading, isSuccess, isError, error, result, reset } =
    useTypedMutation({
      endpointKey: "POST /api/users",
      onSuccess: () => {
        // Bust the list so it refetches next time
        cache.invalidateByEndpoint("GET /api/users");
      },
    });

  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"), email: fd.get("email") }),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button disabled={isLoading}>
        {isLoading ? "Creating…" : "Create User"}
      </button>
      {isError  && <p>Network error: {error?.error.message}</p>}
      {isSuccess && result?.status === 201 && (
        <p>Created user #{result.data.id}!{" "}
          <button type="button" onClick={reset}>Dismiss</button>
        </p>
      )}
    </form>
  );
}

mutateAsync — navigate after success

function CheckoutButton({ cart }: { cart: CartItem[] }) {
  const navigate = useNavigate();
  const { mutateAsync, isLoading } = useTypedMutation({
    endpointKey: "POST /api/checkout",
  });

  const handleCheckout = async () => {
    const result = await mutateAsync("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ items: cart }),
    });
    if (result.status === 200) {
      navigate(`/orders/${result.data.orderId}`);
    }
  };

  return (
    <button onClick={handleCheckout} disabled={isLoading}>
      {isLoading ? "Processing…" : "Checkout"}
    </button>
  );
}

onSuccess / onError callbacks

const { mutate } = useTypedMutation({
  endpointKey: "DELETE /api/users/:id",
  onSuccess: (result) => {
    if (result.status === 204) toast("User deleted");
    cache.invalidateByEndpoint("GET /api/users");
  },
  onError: (err) => {
    toast.error(`Network error: ${err.error.message}`);
  },
  onSettled: () => {
    setConfirmOpen(false);
  },
});

initialData — instant first render

// Pass SSR-fetched data as initialData so the component renders immediately
// without a loading state, even on the first visit.
function UserCard({ id, serverUser }: { id: number; serverUser: User }) {
  const { result } = useTypedFetch(
    `/api/users/${id}`,
    undefined,
    {
      endpointKey: "GET /api/users/:id",
      initialData: { status: 200, ok: true, data: serverUser, response: null as any, endpoint: "GET /api/users/:id" },
    },
  );

  if (result?.status === 200) return <h1>{result.data.name}</h1>;
  return null;
}

Manual TypedFetchUserEndpoints declaration

Declare types for endpoints before they've been observed, or to override generated types:

// src/typed-fetch.endpoints.d.ts
declare module "@phumudzo/typed-fetch" {
  interface TypedFetchUserEndpoints {
    "GET /api/users/:id": {
      200: { id: number; name: string; email: string; role: "admin" | "user" };
      404: { error: string };
    };
    "POST /api/users": {
      201: { id: number; name: string; email: string };
      400: { error: string; fields: Record<string, string> };
    };
  }
}
export {};

Next: Node.js Server Examples

Learn about Node.js Server integration.

On this page