Skip to content

TanStack Query

Complete examples for your app include configuration, components and cleanup. This page covers the integration API.

Connect a live feed to a query key in your existing QueryClient. Your components keep reading that key through TanStack Query; Spinetab shares the subscription that supplies its updates.

These examples assume the bundler plugin and client are already set up.

Terminal window
pnpm add spinetab @tanstack/query-core

@tanstack/query-core is needed for types. React apps usually have it already through @tanstack/react-query.

Create a small function that binds your existing cache. This example receives complete { "n": 1 } values from SSE; any subscription source can replace it.

tick-feed.ts
import type { QueryClient } from "@tanstack/query-core";
import { sse } from "spinetab/sse";
import { bindQuery } from "spinetab/tanstack-query";
import { spinetab } from "./live";
export function startTicks(queryClient: QueryClient) {
return bindQuery(spinetab, sse<{ n: number }>("/api/ticks"), {
queryClient,
queryKey: ["tick"],
reconcile: "latest",
});
}

Mount one bridge for this cache key where you want updates to remain active. Use the same QueryClient as your existing query provider. Your query hooks keep reading ["tick"]; the bridge only supplies live writes and releases its own subscription on cleanup.

TickUpdates.tsx
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { startTicks } from "./tick-feed";
export function TickUpdates() {
const queryClient = useQueryClient();
useEffect(() => {
const binding = startTicks(queryClient);
return () => binding.unsubscribe();
}, [queryClient]);
return null;
}

Render <TickUpdates /> beneath your existing QueryClientProvider. The "use client" boundary is needed in Next.js; Vite and React Router do not require it.

For Astro, put this bridge and its provider in the same hydrated island. In vanilla code, call startTicks(queryClient) when the view mounts and binding.unsubscribe() when it is removed.

Each event writes the whole value to ["tick"]. reconcile: "latest" restores continuity when a new complete value arrives after a gap. Spinetab does not cancel ordinary Query fetches during live delivery: if the same key also fetches snapshots, use server versions to prevent an older snapshot overwriting a newer event. For a stream-only key, disable its query fetch and let the feed populate it.

Behaviour Default
Write setQueryData(queryKey, event), before any onEvent
Loss without a policy or onContinuity gap and unknown reach onError as continuity-lost, once per notice
Errors without onError Reported once through the client’s onCallbackError, else reportError
Resumable states The subscription stays open through reconnecting, retry-exhausted and auth-blocked
Query defaults Focus and online managers, defaults and scheduling are never touched

Pick the policy that matches the feed:

reconcile For What runs after a loss
"latest" Every event carries the whole value Delivery restarts; the next event reconciles
"invalidate" A feed of changes to queryKey invalidateQueries({ queryKey }); reconciled once every matching query has refetched
{ queryKey } Changes to another key The same for that key or prefix
(context) => Promise Anything else Your refresh; reconciled when it resolves

A refresh runs while connected. A newer loss notice aborts its signal and queues another refresh; failure reaches onError and leaves continuity lost. Sharing a feed does not deduplicate these application queries: each tab refreshes its cache.

"invalidate" and { queryKey } confirm recovery only after every matching query refetches successfully. Inactive, disabled, static, paused or missing queries do not confirm it. For a prefix containing such entries, use a more specific key or a custom refresh.

A custom refresh can cancel an older query fetch before writing its result:

bindQuery(spinetab, ticks, {
queryClient,
queryKey: ["tick"],
reconcile: async ({ signal }) => {
await queryClient.cancelQueries({ queryKey: ["tick"], exact: true });
if (signal.aborted) return;
const snapshot = await fetchTick({ signal });
if (!signal.aborted) queryClient.setQueryData(["tick"], snapshot);
},
});

fetchTick is your application fetch and must reject on failure. Pass the signal to the request and check it before writing, so an obsolete refresh cannot replace newer data. For a feed of changes, also merge live events with the snapshot using a server version or watermark; cancellation alone does not establish their order.

Store a field with map, or fold events with reduce(current, event, meta). Both require a queryKey; choose one, not both:

bindQuery(spinetab, ticks, {
queryClient,
queryKey: ["tick"],
map: (tick) => tick.n,
});

Poll at another rate with pollEvery from spinetab/polling; consumer options spread in flat:

import { pollEvery } from "spinetab/polling";
bindQuery(spinetab, queue, {
queryClient,
queryKey: ["queue"],
...pollEvery(30_000),
});

Watch status with the bound tools, which include retry():

bindQuery(spinetab, ticks, { queryClient, queryKey: ["tick"], onStatus: show });

Without queryKey, supply onEvent to control which cache entries change:

bindQuery(spinetab, ticks, {
queryClient,
onEvent: (tick, tools) => tools.setQueryData(["tick"], tick.n),
});

The tools expose setQueryData, setQueriesData, invalidateQueries, getQueryData, markReconciled and retry. Prefer reconcile for refreshing; use onContinuity and markReconciled only when you need manual recovery.

Callback Called with
onEvent Each event in order, the tools and the event metadata
onContinuity Each continuity notice away from continuous (gap, unknown or resumed)
onError Terminal outcomes, an unreconciled loss without a policy, a failed refresh
onStatus Every status change, with the tools