One declaration, every call shape
Declare method, path, and schemas once. Get the hook, the suspense hook, the mutation, the infinite query, the options object, and a plain promise — all typed from the same source.
const user = api.users.detail.withPathParams({ id });
user.useQuery(); // React
user.useSuspenseQuery(); // Suspense
await user.fetch(); // no React at allA closed error channel
Eight tags, exhaustively. A hook that throws a plain Error is normalised rather than escaping untagged, so switching on err.tag is safe to rely on.
switch (err.tag) {
case 'http': return err.status === 404 ? null : retry();
case 'abort': return err.timedOut ? retry() : ignore();
case 'transport': return offline();
}Cancellable everywhere
A signal or a timeout on any call, including outside React. Timeouts use AbortSignal.timeout, so the request actually stops rather than being abandoned.
await api.users.list.fetch(
{ page: 1 },
{ timeout: 5_000 },
);Cache keys that cannot collide
Keys carry the resolved path, so two calls that differ only by path parameter are separate entries. Service and endpoint stay at the front, so prefix invalidation still works.
['users', 'detail', '/users/42']
['users', 'detail', '/users/43']
// two resources, two entries
Bring your own validator
Zod, Effect Schema, Valibot, ArkType — anything implementing safeParse or Standard Schema V1. Zod is an optional peer dependency, not a requirement.
responseSchema: z.object({ name: z.string() })
responseSchema: Schema.standardSchemaV1(User)
responseSchema: v.object({ name: v.string() })Eight places to intervene
Hooks at every lifecycle phase, cascading from API to service to endpoint. Every hook runs, even after one has decided the outcome — so telemetry never gets skipped.
api.users.detail
.onRequest((ctx) => trace(ctx))
.onHttpError(() => ({ fallback: EMPTY_USER }));