Skip to content

tRPC recipes

Subscribe to a typed procedure over WebSocket or SSE.

Connection
Open recipe

tRPC over WebSocket · React Router

Start with an existing React Router app. Keep its renderer, routes and plugins. These files add one live view; Spinetab does not create your API.

Use a tRPC 11 server with a queue subscription returning a complete { open: number } value. The server router below defines that contract; mount it at /trpc-ws with tRPC's WebSocket adapter. The browser imports only its type. This standalone client recipe uses untracked full-state results; tracked events and transformers require their corresponding result handling.

The examples use same-origin URLs and declare anonymous: true: no token is supplied, although cookies still flow. For a separate API origin, use its URL and configure CORS or your application's proxy. For private data, add credentials and user scopes; never put a secret in these files.

Terminal window
pnpm add spinetab @trpc/client@^11.19.0 @trpc/server@^11.19.0

Keep your framework's existing dependencies. See compatible versions if upgrading an older app.

Merge this addition into your configuration; preserve existing plugins and options. Restart the dev server afterwards. The plugin bundles the worker and discovers the adapters imported below.

vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { spinetab } from "spinetab/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [reactRouter(), spinetab()],
optimizeDeps: { entries: ["app/**/*.{ts,tsx}"] },
});

Save these files together in app/recipe/.

reconcile: "latest" fits this full-state contract: the next complete event restores the displayed value after a gap. Use a refresh/merge policy for deltas or partial GraphQL results. Reconnecting alone does not reconstruct missed state.

app/recipe/live.ts
import { createSpinetab } from "spinetab";
export const spinetab = createSpinetab({ anonymous: true });
app/recipe/queue-types.ts
export type Queue = { open: number };
export type QueueView = { open?: number; problem?: string };
app/recipe/router.ts
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
// Server-side contract. Mount this router on your existing tRPC server.
export const appRouter = t.router({
queue: t.procedure.subscription(async function* ({ signal }) {
let open = 0;
while (!signal?.aborted) {
yield { open: open++ };
await new Promise<void>((resolve) => {
const finish = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", finish);
resolve();
};
const timer = setTimeout(finish, 1_000);
signal?.addEventListener("abort", finish, { once: true });
});
}
}),
});
export type AppRouter = typeof appRouter;
app/recipe/watch.ts
import { createTRPCClient, httpBatchLink, splitLink } from "@trpc/client";
import { spinetabWsLink } from "spinetab/trpc";
import { spinetab } from "./live";
import type { QueueView } from "./queue-types";
import type { AppRouter } from "./router";
const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (operation) => operation.type === "subscription",
true: spinetabWsLink<AppRouter>({
client: spinetab,
url: "/trpc-ws",
reconcile: "latest",
}),
false: httpBatchLink({ url: "/trpc" }),
}),
],
});
export function watchQueue(
next: (value: QueueView) => void,
fail: (message: string) => void,
) {
const subscription = trpc.queue.subscribe(undefined, {
onData: (value) => {
fail("");
next(value);
},
onConnectionStateChange: (state) => {
if (state.error) fail(state.error.message);
},
onError: (error) => fail(error.message),
});
return () => subscription.unsubscribe();
}
app/recipe/Recipe.tsx
import { useEffect, useState } from "react";
import type { QueueView } from "./queue-types";
import { watchQueue } from "./watch";
export default function Recipe() {
const [data, setData] = useState<QueueView>();
const [error, setError] = useState("");
useEffect(
() =>
watchQueue((value) => {
setData(value);
setError("");
}, setError),
[],
);
if (error) return <p role="alert">{error}</p>;
if (data?.open === undefined) return <p role="status">Loading queue…</p>;
return (
<p>
<output>{data.open}</output> open
</p>
);
}
app/routes/queue.tsx
import Recipe from "../recipe/Recipe";
export default function QueuePage() {
return (
<main>
<Recipe />
</main>
);
}
app/routes.ts
import { type RouteConfig, route } from "@react-router/dev/routes";
export default [route("queue", "routes/queue.tsx")] satisfies RouteConfig;

Keep your existing routes and root Outlet. The dependency-scan entries include route imports on a cold first visit. Declarative/Data Mode apps use the Vite recipe instead.

Open /queue in two tabs of the same browser profile. Both should show advancing queue values. Matching subscriptions share upstream work when SharedWorker is available; fallback runs independently in each tab. Remove one view and the other should keep updating. Removing the last view releases its subscription; connection closure can follow the adapter's idle delay.

API options and advanced recovery · Authentication