These hooks wrap the TypeScript client with React Query for caching, refetching, and pagination. Drop them in and most of your data layer is done.

Setup

npm i @tanstack/react-query
App.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const qc = new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000, retry: 1 } },
});

export function App() {
  return (
    <QueryClientProvider client={qc}>
      <Routes />
    </QueryClientProvider>
  );
}

The hooks

src/fd/hooks.ts
import { useQuery, useMutation, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
import { fd } from "./client";

// Generic GET — typed via openapi-fetch
export function useFd<Path extends keyof typeof fd.GET extends never ? never : string>(
  path: any, params?: any, opts?: UseQueryOptions<any>,
) {
  return useQuery({
    queryKey: [path, params],
    queryFn:  async () => {
      const { data, error } = await fd.GET(path as any, { params });
      if (error) throw error;
      return data;
    },
    ...opts,
  });
}

// Generic POST/PUT mutation
export function useFdMutation<TBody, TResp>(
  method: "POST" | "PUT" | "DELETE" | "PATCH",
  path: string,
  opts: { invalidate?: string[] } = {},
) {
  const qc = useQueryClient();
  return useMutation<TResp, Error, { params?: any; body?: TBody }>({
    mutationFn: async ({ params, body }) => {
      const fn = (fd as any)[method] as Function;
      const { data, error } = await fn(path, { params, body });
      if (error) throw error;
      return data;
    },
    onSuccess: () => opts.invalidate?.forEach(k => qc.invalidateQueries({ queryKey: [k] })),
  });
}

Domain hooks (illustrative)

src/fd/hooks/events.ts
import { useFd, useFdMutation } from "../hooks";

export const useEvent = (id: string) =>
  useFd("/events/{id}", { path: { id } });

export const useEventsList = (filters: any) =>
  useFd("/events/", { query: filters });

export const useEventStats = (id: string) =>
  useFd("/events/{id}/statistics", { path: { id } });

export const useEventBenchmark = (id: string) =>
  useFd("/events/{id}/benchmark", { path: { id } });

export const useSuggestedTcs = (id: string, budget: number, goal = "tickets") =>
  useFd("/events/{id}/suggested_tcs", { path: { id }, query: { budget, goal } });

export const useSetFrontendStatus = (eid: string) =>
  useFdMutation<{ frontend_status: "FLAGGED" | "DEFAULT" | "HIDDEN" }, void>(
    "PUT", "/events/{id}/setup_frontend_status",
    { invalidate: ["/events/", `/events/${eid}`] },
  );
src/fd/hooks/wave.ts
export const useSetupProcess = (eid: string) =>
  useFd("/setup_processes/{eid}", { path: { eid } });

export const useCreateSetup = (eid: string) =>
  useFdMutation("POST", "/setup_processes/{eid}", {
    invalidate: [`/setup_processes/${eid}`],
  });

export const useSyncSetup = (eid: string) =>
  useFdMutation("PUT", "/setup_processes/{eid}", {
    invalidate: [`/setup_processes/${eid}`],
  });

export const usePublishSetup = (eid: string) =>
  useFdMutation("POST", "/setup_processes/{eid}/boost", {
    invalidate: [
      `/setup_processes/${eid}`,
      `/events/${eid}/campaigns`,
    ],
  });

export const usePackages = () =>
  useFd("/package_builder/packages/");

export const usePackage = (id: string, { poll = true }: { poll?: boolean } = {}) =>
  useFd("/package_builder/packages/{id}", { path: { id } }, {
    refetchInterval: (data: any) => {
      if (!poll) return false;
      const s = data?.status;
      return (s === "INITIALIZING" || s === "INPROGRESS") ? 5000 : false;
    },
  });

Usage

function EventDetail({ eid }: { eid: string }) {
  const event   = useEvent(eid);
  const stats   = useEventStats(eid);
  const bench   = useEventBenchmark(eid);
  const tcs     = useSuggestedTcs(eid, 5000);
  const setFlag = useSetFrontendStatus(eid);

  if (event.isLoading) return <Skeleton />;
  if (event.error?.status === 404) return <NotFound />;

  return (
    <>
      <Hero event={event.data} />
      <StatsBar stats={stats.data} />
      <SalesOverview benchmark={bench.data} />
      <TcSuggestions tcs={tcs.data} />
      <button onClick={() => setFlag.mutate({ body: { frontend_status: "FLAGGED" } })}>
        Flag
      </button>
    </>
  );
}

The mutation pattern for the Wave wizard

function CampaignSetupForm({ eid }: { eid: string }) {
  const setup    = useSetupProcess(eid);
  const create   = useCreateSetup(eid);
  const sync     = useSyncSetup(eid);
  const publish  = usePublishSetup(eid);

  const debouncedSync = useDebouncedCallback((body: SetupProcess) => {
    sync.mutate({ path: { eid }, body });
  }, 1000);

  // ... render form with onChange={debouncedSync} ...

  return (
    <button
      disabled={!isValid(setup.data) || publish.isPending}
      onClick={() => publish.mutate({ path: { eid } })}
    >
      Publish
    </button>
  );
}
For the AbortController pattern that the reference webapp uses (to drop stale PUTs), wrap sync.mutate to abort the previous in-flight call — React Query’s useMutation doesn’t do this by default. See Campaigns Lifecycle.

Caveats

If a user can switch between partners, encode partnerId in every query key (e.g. [partnerId, path, params]) — otherwise switching partners shows stale cached data from the previous tenant.
Use the function signature with data to compute interval per fetch.
React Query invalidation is prefix-match. qc.invalidateQueries({ queryKey: ["/events/"] }) invalidates everything starting with /events/. That’s usually what you want — but be aware of the scope.