Consume a framework-independent SDK with a React-provided client and component-owned request lifetime.
React
Import operations from your SDK or shared definition module. The React application owns the client and provides it through this framework plugin; the SDK package can stay independent of React and be reused by other applications.
These plugins are optional helpers for providing and reading an existing Client. Import a fixed application-owned client, pass a Client explicitly to reusable logic, use framework injection for tree-level defaults and overrides, or choose your own organization. SDK definitions do not require these plugins.
Wire an existing @defjs/core client into a React tree. You get Context and useClient(). The package does not create a client, add a cache, retry operations, or dispose transport resources. The component, effect, or data library that starts work owns it. The client from useClient() is passed into the operation. The hook returns the DerivableClient that was supplied. For per-call options such as signal, call it where the operation runs: getUser(client({ signal }), input). Do not pass it to a state setter, because setState(client) would call it as an updater. A derived Client value is not accepted by ClientProvider or useClient.
Basic Setup
Install @defjs/core, @defjs/react, and React 18+. ESM:
bun add @defjs/core @defjs/react react
Provide the client, then fetch a user and abort on change:
import { createClient, withEndpoint } from "@defjs/core";
import { ClientProvider } from "@defjs/react";
import type { ReactNode } from "react";
const client = createClient(withEndpoint("https://api.example.com"));
export function ApiProvider({ children }: { children: ReactNode }) {
return <ClientProvider client={client}>{children}</ClientProvider>;
}import { defineRequest, struct } from "@defjs/core";
import { useClient } from "@defjs/react";
import { useEffect, useState } from "react";
const getUser = defineRequest({
method: "GET",
path: "/users/:id",
input: struct.object({ id: struct.number() }),
builder: (input) => ({ path: { id: input.id } }),
output: struct.object({ name: struct.string() }),
});
export function UserName({ id }: { id: number }) {
const client = useClient();
const [name, setName] = useState<string>();
useEffect(() => {
const controller = new AbortController();
void getUser(client({ signal: controller.signal }), { id }).then(
([error, user]) => {
if (controller.signal.aborted) return;
if (error) {
setName(undefined);
return;
}
setName(user.name);
},
);
return () => controller.abort();
}, [client, id]);
return <span>{name ?? "Loading..."}</span>;
}ClientProvider is a normal Context provider. A different client prop changes what descendants see — no clone, replace, or dispose. Nested providers create explicit boundaries.
React may set up and clean up an effect more than once in development. The signal check stops a stale promise from writing into the current render. The tuple error is still data.
Read with useClient
useClient(overrideClient) prefers the explicit client; omitted or undefined reads the nearest ClientProvider. An Error is thrown only when neither source supplies a client. Zero-argument useClient() calls remain valid.
Always call useClient(explicitClient) at the top level of a component or custom Hook, even with an explicit client; not in an ordinary function or event callback. Do not write explicitClient ?? useClient(), which conditionally calls a Hook. The override does not write Context or create a provider, and the Hook still subscribes to Context.
import { defineRequest, struct } from "@defjs/core";
import { useClient } from "@defjs/react";
const health = defineRequest({
method: "GET",
path: "/health",
output: struct.object({ ok: struct.boolean() }),
});
export function HealthCheck() {
const client = useClient();
const check = async () => {
const [error, result] = await health(client);
if (error) {
console.error(error.code);
return;
}
console.log(result.ok);
};
return (
<button type="button" onClick={() => void check()}>
Check service
</button>
);
}The hook only supplies the client. It doesn’t start work, subscribe to a transport, or turn the error-first tuple into an exception.
Own query work
A query library can own caching, retries, stale-result suppression, and cancellation. Pass the signal it provides as signal on a derived client. For list pages, call the operation from TanStack Query (or similar) — @defjs/react stays DI only and does not ship useRequest.
import { defineRequest, struct } from "@defjs/core";
import { useCallback } from "react";
import { useClient } from "@defjs/react";
const getUser = defineRequest({
method: "GET",
path: "/users/:id",
input: struct.object({ id: struct.number() }),
builder: (input) => ({ path: { id: input.id } }),
output: struct.object({ name: struct.string() }),
});
export function useUserQueryFn(id: number) {
const client = useClient();
return useCallback(
async ({ signal }: { signal: AbortSignal }) => {
const [error, user] = await getUser(client({ signal }), { id });
// Query libraries record a failure when the query function rejects.
if (error) throw error;
return user;
},
[client, id],
);
}throw error in this hook is the query function's rejection channel, not the default Defjs call. The tuple is inspected first; only this adapter rejects, so the query library can record the failure.
Don’t start the same operation in a second effect — two owners make cancel and stale-result handling ambiguous.
Own realtime work
SSE and WebSocket handles outlive the operation call. Register cleanup before awaiting startup, close a handle that arrives after disposal, consume its single iterator, await its terminal promise:
import { defineWebSocket, struct } from "@defjs/core";
import { useClient } from "@defjs/react";
import { useEffect } from "react";
const notifications = defineWebSocket({
maxIncomingQueueSize: 100,
path: "/notifications",
incoming: struct.object({ text: struct.string() }),
});
export function Notifications() {
const client = useClient();
useEffect(() => {
const controller = new AbortController();
let disposed = false;
let closeActive: (() => void) | undefined;
void (async () => {
const [error, session] = await notifications(
client({ signal: controller.signal }),
);
if (error) return;
let closed = false;
const close = () => {
if (closed) return;
closed = true;
session.close(1000, "effect-disposed");
};
closeActive = close;
if (disposed) {
close();
await session.closed;
return;
}
try {
for await (const message of session.receive) {
console.info(message.text);
}
} catch (cause) {
// Aborting `controller` ends the session and rejects `receive` with AbortError; only report real failures.
if (!disposed) console.error("notifications failed", cause);
} finally {
close();
await session.closed;
}
})();
return () => {
disposed = true;
controller.abort();
closeActive?.();
};
}, [client]);
return null;
}The signal passed to the operation keeps governing an opened session. Aborting it in the cleanup ends the session as aborted and rejects receive with AbortError, so the catch above ignores errors once the effect is disposed and reports only real failures (queue overflow, heartbeat acknowledgement timeout). An aborted SSE stream instead ends its iterator normally. Without the catch, a rejection from the loop would be an unhandled promise rejection.
Same rule for EventStreamHandle: close in finally, await stream.closed. WebSocket consumers must also unsubscribe state/runtime-error listeners and keep reading session.receive — an unread bounded queue can overflow.
SSR and client scope
The package entry is a Client Component boundary. A browser app can share a module-scoped client when endpoint, interceptors, and captured state are browser-safe and request-independent. For SSR, create a separate client inside each request boundary when headers, cookies, users, tenants, or credentials differ.
Provider unmount does not abort HTTP, close SSE/WebSocket, unsubscribe listeners, or call dispose. @defjs/react has no such lifecycle API. The code that starts each operation must finish or cancel it.
Reference
Public exports from @defjs/react:
ClientProvider— acceptsClientProviderProps, provides the supplied clientuseClient— explicit client first, otherwise nearest provided client; throws if neither existsClientProviderProps—{ client: Client; children?: ReactNode }
Create clients and options in @defjs/core. See Client, Errors, SSE, and WebSocket.