Skip to content

tRPC recipes

Subscribe to a typed procedure over WebSocket or SSE.

Connection
Open recipe

tRPC over SSE · Vite · React

Start with an existing Vite · React 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 with tRPC's HTTP/SSE 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 react from "@vitejs/plugin-react";
import { spinetab } from "spinetab/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [react(), spinetab()] });

Save these files together in src/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.

src/recipe/live.ts
import { createSpinetab } from "spinetab";
export const spinetab = createSpinetab({ anonymous: true });
src/recipe/queue-types.ts
export type Queue = { open: number };
export type QueueView = { open?: number; problem?: string };
src/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;
src/recipe/watch.ts
import { createTRPCClient, httpBatchLink, splitLink } from "@trpc/client";
import { spinetabSseLink } 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: spinetabSseLink<AppRouter>({
client: spinetab,
url: "/trpc",
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();
}
src/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>
);
}
src/App.tsx
import Recipe from "./recipe/Recipe";
export default function App() {
return <Recipe />;
}

Your existing entry point mounts App as usual. Keep its renderer plugin; no additional application provider is required beyond those shown.

Open the page 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