@dyadpy/react
v0.2.5-alpha.0
Published
React hooks (useQuery, useMutation, useSubscription) on top of TanStack Query for Dyadpy-generated clients.
Downloads
1,222
Maintainers
Readme
@dyadpy/react
React hooks for Dyadpy generated clients, built on TanStack Query.
pnpm add @dyadpy/react@alpha @dyadpy/ts@alpha @tanstack/react-query
# alpha channel — drop `@alpha` once v0.1.0 ships@dyadpy/react adds hook leaves on top of your generated client:
| Hook | Wraps | For |
| ----------------- | --------------------------- | ----------------------------------------------------- |
| useQuery | useQuery (TanStack) | Unary endpoints. Cache, refetch, invalidate. |
| useMutation | useMutation (TanStack) | Endpoints called imperatively (POST, PATCH, etc). |
| useSubscription | (custom, async-iter driven) | Streaming endpoints (stream[T] on the Python side). |
Types come straight from the generated nested ApiRoutes interface. Args,
hook data, mutation variables, stream events, and the typed error union
from @raises(...) are inferred from the generated client. No extra
codegen.
Setup
Wrap your app in TanStack's QueryClientProvider as usual, then create
the bound hooks once:
// src/lib/dyadpy/hooks.ts
import { createReactClient } from "@dyadpy/react";
import { api, routeMeta } from "./client"; // generated by `dyadpy dev`
export const dyad = createReactClient(api, routeMeta);If your frontend proxies API requests to avoid CORS, point the generated client at the proxy path before binding React hooks:
// Vite/Next dev proxy or reverse proxy forwards /api/dyadpy -> Python server
import { createReactClient } from "@dyadpy/react";
import { createApi, routeMeta } from "./client";
const api = createApi({ baseUrl: "/api/dyadpy" });
export const dyad = createReactClient(api, routeMeta);The React Query QueryClient stays normal; transport config belongs to
createApi({ baseUrl, headers, fetch }), and every hook leaf uses that
same configured client.
// src/main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);SSR Prefetch
Use @dyadpy/react/server from React Server Components or other server
loaders to prefetch into a TanStack QueryClient. Create a request-scoped
Dyadpy client so auth headers and the server baseUrl do not leak across
requests:
import { dehydrate, QueryClient } from "@tanstack/react-query";
import { prefetchQuery } from "@dyadpy/react/server";
import { forwardHeaders } from "@dyadpy/ts";
import { headers } from "next/headers";
import { createApi, routeMeta } from "./client";
import { createReactClient } from "@dyadpy/react";
const queryClient = new QueryClient();
const api = createApi({
baseUrl: process.env.DYADPY_API_URL,
headers: forwardHeaders(await headers()),
});
const dyad = createReactClient(api, routeMeta);
await prefetchQuery(queryClient, dyad.issues.byId, { issueId: 1 });
const dehydrated = dehydrate(queryClient);The query key is the same [methodName, args] shape used by the nested
hook leaf, so hydrated client components pick up prefetched data without a
second request.
useQuery
function IssueDetail({ issueId }: { issueId: number }) {
const { data, error, isLoading } = dyad.issues.byId.useQuery({ issueId });
if (isLoading) return <Spinner />;
// `error` is typed as `IssueNotFound | Forbidden` — the Python `@raises(...)` union.
if (error) return <ErrorView kind={error.kind} />;
return <h1>{data.title}</h1>;
}Route leaves are always explicit. A GET /chat route is
api.chat.list() / dyad.chat.list.useQuery(), not api.chat(). That
keeps the same shape when /chat/messages or /chat/events are added
later.
The default query key is [methodName, args]. Override it via
options.queryKey when you need a custom cache key.
dyad.issues.list.useQuery(
{ status: "open" },
{
queryKey: ["issues", "open"],
staleTime: 60_000,
},
);All other TanStack UseQueryOptions (enabled, staleTime,
refetchInterval, select, …) pass through.
useMutation
function NewIssueForm() {
const queryClient = useQueryClient();
const createIssue = dyad.issues.create.useMutation({
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["listIssues"] }),
});
return (
<form
onSubmit={async (e) => {
e.preventDefault();
await createIssue.mutateAsync({ data: { title: "..." } });
}}
>
...
</form>
);
}mutate / mutateAsync take exactly the args the generated method
expects. error is the typed error union.
useSubscription
function ActivityFeed() {
const queryClient = useQueryClient();
const { status, error } = dyad.activity.list.useSubscription({
onEvent: (ev) => {
// ev is the typed union from the Python stream return.
if (ev.kind === "created") {
queryClient.invalidateQueries({ queryKey: ["listIssues"] });
}
},
});
if (status === "error") return <ErrorView error={error} />;
return <Badge status={status} />;
}status is "idle" | "connecting" | "open" | "closed" | "error". The
hook starts the stream on mount, aborts it on unmount, and re-subscribes
when args change (compared by structural value). Pass enabled: false
to defer.
onEvent / onOpen / onClose / onError are captured by ref, so
inline arrow functions are fine — the stream is not re-established when
they change identity.
How Result<T, E> is handled
Dyadpy routes decorated with @raises(...) return a Result<T, E>
envelope. @dyadpy/react unwraps it automatically:
{ ok: true, data }→datais the hook's.data{ ok: false, error }→erroris thrown so TanStack Query surfaces it via.error, fully typed as the discriminated union from the Python side
Endpoints without @raises(...) (no envelope) are passed through as-is.
Peer dependencies
react≥ 18@tanstack/react-query^5@dyadpy/ts(workspace; required by your generated client)
License
MIT.
