Skip to content
qt

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.

1. Define tags
// 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"],
  },
})
2. Tag queries on each route
// 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()] },
})
3. Server function invalidates both at once
// 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.

Server: scope-based system
// 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,
  }),
})
Server: scoped invalidation
// Invalidate only for a specific user
await tagInvalidation.invalidateTags({
  tags: [appTags.todos()],
  scope: { kind: "user", id: user.id },
  context: { userId: user.id },
})
Client: connect to the user's SSE channel
// 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>
  )
}
Scope kinds: "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
// 1. Tag the query as usual
const todosQuery = useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
  meta: { tags: [appTags.todos.list()] },
})
2. Mutation with optimistic + rollback
// 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"] })
  },
})
Always call 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
// 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
Targeted vs. broad invalidation
// 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(),
  ],
})
Design your tag tree to mirror your data graph. A flash-sale invalidates the whole product group; a single price edit invalidates only the affected leaves. Both use the same 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
Adapters exist for Hono, Express, Fastify, Elysia, and Koa — all zero-dependency, accepting the same { 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.

Query factories (define once)
// 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)],
  )
Loader + component usage
// 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.

Redis broadcast setup
// 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) }),
})
The 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.