Skip to Content
DocsReact HooksClient Proxy SDK

Client Proxy SDK (createClient)

The @explita/actyx-rpc-react package provides createClient, a lightweight TypeScript proxy that turns your backend router into a fully typed client.

Instead of writing manual API routes, URL query strings, or boilerplate fetch calls, you can call any procedure on your router directly or through React hooks with end-to-end autocomplete and type safety.

┌─────────────────────────┐ HTTP / SSE / WS ┌──────────────────────────┐ │ Client Proxy (rpc) │ ────────────────────────────────> │ Next.js Route Handler │ │ - rpc.todos.list() │ │ createHandler(appRouter) │ │ - .useQuery() │ │ /api/rpc/[...rpc] │ │ - .useMutation() │ <──────────────────────────────── │ │ └─────────────────────────┘ [data, error] └──────────────────────────┘

1. Defining the Router & Route Handler

Before using the client proxy, assemble your procedures into a router using createRouter and export appRouter along with its type AppRouter:

// backend/router.ts import { createRouter } from "@explita/actyx-rpc"; import { todoProcedures } from "./todos"; import { mediaProcedures } from "./media"; export const appRouter = createRouter({ todos: createRouter({ list: todoProcedures.list, add: todoProcedures.add, }), media: createRouter({ upload: mediaProcedures.upload, uploadAvatar: mediaProcedures.uploadAvatar, }), }); // Export the router type for the client proxy export type AppRouter = typeof appRouter;

Mount appRouter in your Next.js route handler (app/api/rpc/[...rpc]/route.ts):

// app/api/rpc/[...rpc]/route.ts import { createHandler } from "@explita/actyx-rpc/adapters/next"; import { appRouter } from "@/backend/router"; export const { GET, POST } = createHandler(appRouter);

2. Initializing the Client

Create a client instance by passing your backend AppRouter type to createClient:

// lib/rpc/client.ts import { createClient } from "@explita/actyx-rpc-react"; import type { AppRouter } from "@/backend/router"; export const rpc = createClient<AppRouter>({ baseUrl: "/api/rpc", // Optional: Dynamic authentication headers headers: async () => { const token = await getSessionToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }, });

Client Options

OptionTypeDefaultDescription
baseUrlstringRequiredThe API endpoint where createHandler is mounted (e.g. "/api/rpc").
headersRecord<string, string> | (() => Promise<Record<string, string>>)undefinedCustom static headers or an async getter function evaluated on each request.
routing"path" | "query""path"Routing strategy: "path" (/api/rpc/todos.list) or "query" (/api/rpc?procedure=todos.list).
queryMethod"GET" | "POST""GET"Default HTTP method used when executing queries.
fetchtypeof fetchglobalThis.fetchCustom fetch implementation (e.g. for testing, mocks, or Axios-style interceptors).

3. Direct Procedure Execution

Every procedure on the proxy can be invoked directly as a function. It returns a promise that resolves to Actyx RPC’s canonical [data, error] tuple:

// Direct call without hooks — event handlers, background scripts, or client utilities const [data, error] = await rpc.todos.add({ text: "Prepare launch announcement", }); if (error) { console.error("Failed to add todo:", error.message); return; } console.log("Created todo ID:", data.id);

Standard Promise Methods

Direct calls also support .then(), .catch(), and .finally():

rpc.todos.add({ text: "Write documentation" }) .then(([data, error]) => { if (data) showSuccessToast(); }) .catch((err) => { console.error("Network error:", err); });

[!WARNING] Server-to-Server Overhead: Avoid Calling rpc.* in React Server Components

Calling a client proxy procedure from the server like:

// ⚠️ In a Server Component or Server Action — AVOID THIS: const [res, err] = await rpc.todos.list();

is a waste of resources because your server creates an unnecessary HTTP loopback request over the network (or localhost) to its own API route instead of executing in-memory. Additionally, it requires configuring an absolute baseUrl (since Node.js fetch cannot resolve relative URLs like "/api/rpc").

Always invoke the router directly on the server:

// ✅ In a Server Component, Server Action, or Route Handler — DO THIS: import { appRouter } from "@/backend/router"; const [res, err] = await appRouter.todos.list();

Direct router calls execute in-process with zero HTTP overhead, zero serialization latency, and native database/context access.

When should you use createClient on the server? Only when making truly external cross-service calls across different backends or microservices (e.g. Service A calling Service B over HTTP via createClient<ServiceBRouter>({ baseUrl: "https://api.service-b.com/api/rpc" })).


4. React Hooks on the Proxy

Every procedure on the proxy exposes built-in React hooks that bind directly to the React QueryClient:

useQuery

function TodoList() { const { data: todos, isLoading, error, refetch } = rpc.todos.list.useQuery({ unwrap: true, // Returns array directly instead of tuple staleTime: "1m", }); if (isLoading) return <div>Loading todos...</div>; if (error) return <div>Error: {error.message}</div>; return ( <ul> {todos.map((todo) => ( <li key={todo.id}>{todo.text}</li> ))} </ul> ); }

useMutation

function AddTodoForm() { const { mutate, isPending, error } = rpc.todos.add.useMutation({ onSuccess(newTodo) { toast.success("Todo added!"); // Automatically refresh the list rpc.todos.list.invalidate(); }, onError(err) { toast.error(err.message); }, }); return ( <button disabled={isPending} onClick={() => mutate({ text: "Finish tasks" })} > {isPending ? "Adding..." : "Add Todo"} </button> ); }

usePaginatedQuery

Bi-directional cursor or offset pagination with zero boilerplate:

function PaginatedPosts() { const { data: posts, page, totalPages, nextPage, prevPage } = rpc.posts.list.usePaginatedQuery({ pageSize: 10 }); return ( <div> {posts.map((post) => ( <p key={post.id}>{post.title}</p> ))} <button onClick={prevPage} disabled={page === 1}>Previous</button> <button onClick={nextPage} disabled={page === totalPages}>Next</button> </div> ); }

useInfiniteQuery

Infinite scroll feeds that automatically accumulate pages:

function Feed() { const { data: posts, fetchNextPage, hasNextPage, isFetchingNextPage } = rpc.posts.feed.useInfiniteQuery({ getNextPageParam: (lastPage) => lastPage.nextCursor, }); return ( <div> {posts.map((post) => ( <article key={post.id}>{post.title}</article> ))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}> {isFetchingNextPage ? "Loading..." : "Load More"} </button> )} </div> ); }

useSuspenseQuery

First-class React Suspense integration for server-rendered or async boundaries:

function UserHeader() { const { data: user } = rpc.users.current.useSuspenseQuery(); return <div>Welcome back, {user.name}!</div>; }

5. File Uploads & Progress Tracking

The client proxy features automatic binary detection and multipart serialization. You don’t have to manually construct FormData or manage boundary headers.

Bare File Upload

Pass a raw File or Blob directly as the input:

function SingleFileUpload() { const { mutate, isPending, progress } = rpc.media.uploadAvatar.useMutation({ onProgress(percent) { console.log(`Upload: ${percent}%`); }, onSuccess(fileInfo) { toast.success(`Uploaded ${fileInfo.name}`); }, }); const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (file) mutate(file); // Direct bare file! }; return ( <div> <input type="file" onChange={onFileChange} /> {isPending && <div>Uploading: {progress}%</div>} </div> ); }

Objects with Files and Arrays (File[])

Pass objects containing single files, multiple files, or file arrays. The client automatically constructs bracket notation (files[0], user[avatar]) while preserving primitive types:

// Nested structure with files and primitives await rpc.posts.create({ title: "My Portfolio", tags: ["design", "art"], coverImage: coverFile, // File attachments: [file1, file2], // File[] });

Direct Upload Calls with Progress

You can also track upload progress on direct non-hook calls:

const [data, error] = await rpc.media.upload(file, { onProgress(percent) { console.log(`Upload progress: ${percent}%`); }, });

6. Procedure Cache & Lifecycle Utilities

Procedures on the proxy expose direct inspection and cache management utilities without needing to manually construct query keys:

// 1. Invalidation: rpc.todos.list.invalidate(); // Invalidate all cached queries for todos.list rpc.todos.get.invalidate({ id: "123" }); // Invalidate matching specific input // 2. Cache inspection & manipulation: const cachedTodos = rpc.todos.list.getQueryData(); rpc.todos.list.setQueryData([...cachedTodos, newTodo]); // 3. Access exact generated query keys: const key = rpc.todos.get.getQueryKey({ id: "123" }); // 4. Track fetching / mutating states anywhere: const isListFetching = rpc.todos.list.isFetching(); const isTodoAdding = rpc.todos.add.isMutating(); // 5. Reset cached query to idle/initial state: rpc.todos.list.reset();

7. Real-Time Streaming (SSE & WebSockets)

Procedures configured with .sse(), .stream(), or .ws() can be consumed via hooks or native async iteration:

Server-Sent Events & Generator Streams (.stream() & .useSSE())

Both .sse() and .stream() procedures can be consumed either reactively with .useSSE() or directly via .stream():

Reactive Hook (useSSE)

Use .useSSE() inside React components for automatic connection management, reconnects, and reactive state (data, lastData, isConnected, error):

function NotificationFeed() { const { lastData, isConnected } = rpc.notifications.useSSE(); return ( <div> <span>Status: {isConnected ? "Connected" : "Disconnected"}</span> <p>Latest: {lastData?.message}</p> </div> ); }

Direct Unfiltered Stream

For scripts, event listeners, or non-React contexts, invoke the procedure directly to get an AsyncIterable with zero React overhead:

// Direct call with native for-await-of loop const stream = rpc.notifications({ channel: "alerts" }); for await (const event of stream) { console.log("Stream event:", event); } // Manually abort stream when done stream.close();

Full-Duplex WebSockets (useWS)

Procedures configured with .ws() can be consumed using useWS:

function LiveChat() { const { send, lastMessage, isConnected } = rpc.chat.useWS(); return ( <div> <p>Status: {isConnected ? "Online" : "Offline"}</p> <button onClick={() => send({ text: "Hello!" })}>Send</button> </div> ); }

Async Iteration on the Proxy

You can consume SSE and generator streams using standard for await loops:

for await (const event of rpc.liveActivity({ channelId: "123" })) { console.log("New event:", event); }
Last updated on