Skip to content

SWR recipes

Use shared subscriptions in a React app with SWR.

Open recipe

GraphQL over SSE · 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.

Your schema exposes Subscription.queue: Queue! with Queue.open: Int!. Each event contains the complete queue value. Serve the graphql-sse distinct protocol at /graphql/stream. Queries and mutations, if used, go to /graphql. This is a GraphQL endpoint, not a plain SSE or WebSocket feed.

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 graphql@^17.0.2 graphql-sse@^2.6.1 swr@^2.5.1

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/.

SWR uses its default cache. The constant queue key always returns the same source; if the endpoint varies, derive it from that key. With SWR 2.5.1 and React 19.3, avoid nesting a custom cache provider inside StrictMode; see the upstream restriction.

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/source.ts
import { parse } from "graphql";
import {
type GraphqlDocument,
type GraphqlResult,
graphqlSse,
} from "spinetab/graphql-sse";
import type { Queue, QueueView } from "./queue-types";
const document: GraphqlDocument<
{ queue: Queue },
Record<string, never>
> = parse("subscription Queue { queue { open } }");
export const queueSource = graphqlSse("/graphql/stream").subscription({
query: document,
});
export function selectQueue(
result: GraphqlResult<{ queue: Queue }>,
): QueueView {
return {
open: result.data?.queue?.open,
problem:
result.errors?.map((error) => error.message).join("; ") ||
(result.data?.queue ? undefined : "The server returned no queue."),
};
}
app/recipe/Recipe.tsx
import { swrSubscription } from "spinetab/swr";
import useSWRSubscription from "swr/subscription";
import { spinetab } from "./live";
import { queueSource, selectQueue } from "./source";
const subscribe = swrSubscription(spinetab, (_key: "queue") => queueSource, {
map: selectQueue,
reconcile: "latest",
});
export default function Recipe() {
const { data, error } = useSWRSubscription("queue", subscribe);
if (error || data?.problem)
return <p role="alert">{error?.message ?? data?.problem}</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