Skip to content

TanStack Query recipes

Feed shared updates into your existing query cache.

Open recipe

Socket.IO · Next.js

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

Your Socket.IO server emits socket.emit("queue", { open: 12 }) in the default namespace, using the default /socket.io path. Each event contains the complete queue. The example chooses sharing: "shared": use it only if merging tabs onto one socket suits your server's presence and membership rules.

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 socket.io-client@^4.8.4 @tanstack/query-core@^5.104.0 @tanstack/react-query@^5.104.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.

next.config.ts
import type { NextConfig } from "next";
import { withSpinetab } from "spinetab/next";
const config: NextConfig = {};
export default withSpinetab(config);

Save these files together in app/recipe/.

The provider owns a fresh QueryClient. If your app already has a provider, render Queue beneath it instead of creating another one. The query uses skipToken to read live cache updates without fetching a competing snapshot. The bridge uses that same client's ["queue"] key.

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 { socketIo } from "spinetab/socket-io";
import type { Queue, QueueView } from "./queue-types";
export const queueSource = socketIo("/", { sharing: "shared" }).subscription<
[Queue]
>({ event: "queue" });
export const selectQueue = ([queue]: [Queue]): QueueView => queue;
app/recipe/feed.ts
import type { QueryClient } from "@tanstack/query-core";
import { bindQuery } from "spinetab/tanstack-query";
import { spinetab } from "./live";
import { queueSource, selectQueue } from "./source";
export function startQueue(
queryClient: QueryClient,
onError: (message: string) => void,
) {
return bindQuery(spinetab, queueSource, {
queryClient,
queryKey: ["queue"],
map: selectQueue,
reconcile: "latest",
onError: (error) => onError(error.message),
onEvent: () => onError(""),
});
}
app/recipe/Queue.tsx
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { startQueue } from "./feed";
import type { QueueView } from "./queue-types";
export default function Queue() {
const client = useQueryClient();
const { data } = useQuery<QueueView>({
queryKey: ["queue"],
queryFn: skipToken,
});
const [error, setError] = useState("");
useEffect(() => {
const binding = startQueue(client, setError);
return () => binding.unsubscribe();
}, [client]);
if (error || data?.problem)
return <p role="alert">{error || data?.problem}</p>;
if (data?.open === undefined) return <p role="status">Loading queue…</p>;
return (
<p>
<output>{data.open}</output> open
</p>
);
}
app/recipe/Recipe.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import Queue from "./Queue";
export default function Recipe() {
const [client] = useState(() => new QueryClient());
return (
<QueryClientProvider client={client}>
<Queue />
</QueryClientProvider>
);
}
app/Live.tsx
"use client";
import Recipe from "./recipe/Recipe";
export default function Live() {
return <Recipe />;
}
app/page.tsx
import Live from "./Live";
export default function Page() {
return (
<main>
<Live />
</main>
);
}

The "use client" boundary includes the view and its imports. Keep subscriptions out of Server Components, loaders and server actions. Retain your existing root layout; use src/app/ instead if that is your app directory. Both supported Next bundlers use this configuration.

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