Cookbook
Copy-paste patterns for real apps
Seven self-contained recipes covering the scenarios you'll hit in production. Each snippet is accurate to the actual library API — drop it in and adjust to your data model.
Recipe 01
Cross-page invalidation
One server action invalidates queries on multiple routes simultaneously — even routes the user isn't currently viewing.
Define tags that span route boundaries. When a todo is created, both the todo-list page and the dashboard stats refresh — without the mutation knowing anything about component structure.
// src/lib/tag-contract.ts
import { defineTags } from "@tanstack-tools/query-tags"
export const appTags = defineTags({
todos: {
list: () => ["todos"],
summary: () => ["todos", "summary"],
byId: (id: string) => ["todo", id],
},
dashboard: {
stats: () => ["dashboard", "stats"],
},
})// components/TodoList.tsx — query on /todos route
useQuery({
queryKey: ["todos", "list"],
queryFn: fetchTodos,
meta: { tags: [appTags.todos.list()] },
})
// components/DashboardStats.tsx — query on /dashboard route
useQuery({
queryKey: ["dashboard", "stats"],
queryFn: fetchStats,
meta: { tags: [appTags.dashboard.stats()] },
})// Server function: creating a todo invalidates both routes at once
export const createTodoFn = createServerFn({ method: "POST" })
.validator(z.object({ name: z.string() }))
.handler(async ({ data }) => {
await db.insert(todos).values({ name: data.name })
// One call refreshes both the list page AND the dashboard stats —
// even if the user is currently viewing a different tab.
await tagInvalidation.invalidateTags({
tags: [appTags.todos(), appTags.dashboard.stats()],
})
})appTags.todos() (called with no arguments) is a group tag — it expands to every leaf under todos.*. You never need to enumerate child tags at the invalidation call site.Recipe 02
Per-user isolation
Each user's queries live in their own invalidation scope. Mutating user A's data never causes user B to refetch.
Pass resolveScope to createTagInvalidationSystem(). The scope key is appended to the SSE URL so each client subscribes to only their channel.
// src/lib/tag-invalidation.server.ts
import { createTagInvalidationSystem } from "@tanstack-tools/query-tags/server"
export const tagInvalidation = createTagInvalidationSystem({
// Each user gets an isolated SSE channel.
// Invalidating user A never triggers a refetch for user B.
resolveScope: (ctx: { userId: string }) => ({
kind: "user",
id: ctx.userId,
}),
})// Invalidate only for a specific user
await tagInvalidation.invalidateTags({
tags: [appTags.todos()],
scope: { kind: "user", id: user.id },
context: { userId: user.id },
})// Pass the user's scope key so the client subscribes to the right SSE channel.
// TagInvalidationProvider reads the scopeKey from the SSE URL.
function App() {
const { user } = useAuth()
return (
<TagInvalidationProvider
queryClient={queryClient}
router={router}
sseUrl={invalidatorClient.sseUrl({ scopeKey: `user:${user.id}` })}
>
<RouterProvider router={router} />
</TagInvalidationProvider>
)
}"global" (default), "user", "tenant", or "custom". The scope key is deterministic — the same user always connects to the same SSE channel on any server instance.Recipe 03
Optimistic updates + tag confirmation
Show UI changes instantly via optimistic cache writes, then let SSE-driven tag invalidation confirm the real server state.
The pattern: onMutate writes to the cache optimistically, onError rolls back, and onSettled ensures a final invalidation. The server's invalidateTags() call drives the authoritative refetch via SSE.
// 1. Tag the query as usual
const todosQuery = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
meta: { tags: [appTags.todos.list()] },
})// 2. Mutation: update cache optimistically, let server + SSE confirm
const addMutation = useMutation({
mutationFn: (name: string) => createTodoFn({ data: { name } }),
onMutate: async (name) => {
// Prevent the in-flight refetch from overwriting our optimistic value
await queryClient.cancelQueries({ queryKey: ["todos"] })
// Snapshot + optimistically set
const previous = queryClient.getQueryData<Todo[]>(["todos"]) ?? []
queryClient.setQueryData(["todos"], [{ id: -1, name, status: "pending" }, ...previous])
return { previous }
},
onError: (_err, _name, ctx) => {
// Roll back on failure
queryClient.setQueryData(["todos"], ctx?.previous)
},
onSettled: () => {
// The server also calls invalidateTags() — SSE will trigger a fresh
// refetch regardless of success or failure. This is the safety net.
queryClient.invalidateQueries({ queryKey: ["todos"] })
},
})queryClient.cancelQueries() in onMutate before writing optimistically. Without it, an in-flight refetch can overwrite your optimistic value before the server confirms.Recipe 04
Cascading invalidation
Model your tag hierarchy to match your data hierarchy. A single invalidation call fans out precisely — no over-invalidation.
Parent tag nodes expand to all descendant leaves. Fine-grained leaf tags let you invalidate a single product's price without touching unrelated queries.
// Tag hierarchy — parent nodes cascade to all children
export const appTags = defineTags({
products: {
list: () => ["products"],
byId: (id: string) => ["product", id],
stock: (id: string) => ["product", id, "stock"],
price: (id: string) => ["product", id, "price"],
},
cart: {
items: () => ["cart", "items"],
totals: () => ["cart", "totals"],
},
})
// appTags.products() → invalidates list + byId(*) + stock(*) + price(*)
// appTags.products.byId("x") → invalidates only that one product
// appTags.cart() → invalidates items + totals// Flash sale: one call fans out across the entire product hierarchy
await tagInvalidation.invalidateTags({
tags: [
appTags.products(), // all product queries
appTags.cart(), // cart re-prices itself too
],
})
// Price change on a single product: only that product + its cart entry
await tagInvalidation.invalidateTags({
tags: [
appTags.products.byId(productId),
appTags.products.price(productId),
appTags.cart.totals(),
],
})invalidateTags() call — the hierarchy does the work.Recipe 05
SPA + separate backend
Not using TanStack Start? Mount the HTTP adapter on any Node.js server and connect with createHTTPInvalidatorClient() in your React app.
The framework adapters expose the same SSE + control API as TanStack Start server functions. The client side is identical — just swap the client constructor.
// backend/server.ts (Hono — same shape for Express, Fastify, Elysia, Koa)
import { Hono } from "hono"
import { createTagInvalidationSystem } from "@tanstack-tools/query-tags/server"
import { createHonoAdapter } from "@tanstack-tools/query-tags/adapters/hono"
const tagInvalidation = createTagInvalidationSystem()
const app = new Hono()
// Mount the SSE + control API under /api/invalidator
app.route("/api/invalidator", createHonoAdapter(tagInvalidation))
// Your existing mutation routes call invalidateTags()
app.post("/api/todos", async (c) => {
const body = await c.req.json()
await db.insert(todos).values(body)
await tagInvalidation.invalidateTags({ tags: [appTags.todos()] })
return c.json({ ok: true })
})
export default app{ basePath?, authenticate? } options. See the SPA setup guide for a full walkthrough.Recipe 06
withTags() query factories
Centralize query definitions with withTags() so tags are colocated with queryFn — never scattered across component files.
Define your query factories once in a dedicated file. Loaders and components import the same factory — the tag lives in exactly one place, and refactoring is a single-file change.
// src/queries/todos.ts — shared query factories
import { withTags } from "@tanstack-tools/query-tags"
// Define once, use everywhere — no meta object scattered across files
export const todosListQuery = () =>
withTags(
{ queryKey: ["todos"], queryFn: fetchTodos },
[appTags.todos.list()],
)
export const todoByIdQuery = (id: string) =>
withTags(
{ queryKey: ["todo", id], queryFn: () => fetchTodoById(id) },
[appTags.todos.byId(id)],
)// Usage in components — one import, no boilerplate
import { todosListQuery, todoByIdQuery } from "@/queries/todos"
// In a route loader (data prefetched, Suspense-friendly)
export const Route = createFileRoute("/todos")({
loader: ({ context }) =>
context.queryClient.ensureQueryData(todosListQuery()),
})
// In a component (cache already warm from loader)
function TodoList() {
const { data } = useSuspenseQuery(todosListQuery())
return <ul>{data.map(t => <li key={t.id}>{t.name}</li>)}</ul>
}
// Single item
function TodoDetail({ id }: { id: string }) {
const { data } = useSuspenseQuery(todoByIdQuery(id))
return <article>{data.name}</article>
}withTags(options, tags) sets both meta.tags and derives a stable queryKey from the tag values when no explicit key is provided. Use it as the single source of truth for each query shape.Recipe 07
Multi-instance with Redis broadcast
Running multiple server replicas? Use the Redis broadcast adapter to fan out SSE events across all instances without a message-bus framework.
The default broadcast adapter is in-process (no deps, zero config). For multi-replica deployments, swap in the Redis adapter. Each replica receives the event from Redis and pushes it to its own connected SSE clients.
// src/lib/tag-invalidation.server.ts — for multi-replica deployments
import { createTagInvalidationSystem } from "@tanstack-tools/query-tags/server"
import { createRedisBroadcast } from "@tanstack-tools/query-tags/redis"
import KeyvRedis from "@keyv/redis"
import Keyv from "keyv"
// BroadcastAdapter fans out SSE events to all server instances.
// Each replica receives the event and pushes it to its own connected clients.
const broadcast = createRedisBroadcast({
publisher: new Redis(process.env.REDIS_URL),
subscriber: new Redis(process.env.REDIS_URL),
})
export const tagInvalidation = createTagInvalidationSystem({
broadcast,
// Optionally store the tag registry in Redis so all instances share it:
store: new Keyv({ store: new KeyvRedis(process.env.REDIS_URL) }),
})createRedisBroadcast() adapter uses two separate Redis clients — one for publishing, one for subscribing. This matches the Redis pub/sub requirement that a subscribing client cannot issue other commands.In-process (default)
createLocalBroadcast() — built-in, zero deps. Works for single-instance deployments.
Redis
createRedisBroadcast() from /redis. Pub/sub across all replicas.
Custom
Implement BroadcastAdapter interface. Works with any pub/sub system (Kafka, NATS, etc.).
See it live
Explore interactive demos
Every pattern above has a live demo you can interact with in the browser — no setup required.