Skip to content
qt

Best Practices

Tag design & best practices

Tags are the contract between server mutations and client queries. A well-designed tag tree makes cache invalidation precise and effortless. A poorly designed one leads to stale data or thrashing refetches.

Structure

Hierarchical vs flat tags

Grouping related tags under a shared parent lets you invalidate an entire domain with one call — without listing every leaf tag individually.

Flat tags

// Flat — all tags live at the same level
export const const appTags: anyappTags = defineTags({
  todoList: () => string[]todoList:    () => ["todoList"],
  todoSummary: () => string[]todoSummary: () => ["todoSummary"],
  todoById: (id: string) => string[]todoById:    (id: stringid: string) => ["todo", id: stringid],
  notes: () => string[]notes:       () => ["notes"],
  noteById: (id: string) => string[]noteById:    (id: stringid: string) => ["note", id: stringid],
})

// Invalidating all todo-related queries requires listing every tag:
await tagInvalidation.invalidateTags({
  tags: any[]tags: [const appTags: anyappTags.todoList(), const appTags: anyappTags.todoSummary()],
  // forgot todoById(*) — stale data for single-item views
})
Flat trees force you to enumerate every related tag on every invalidation call. Miss one and you get stale data.

Hierarchical tags

// Hierarchical — branches group related leaves
export const const appTags: anyappTags = defineTags({
  
todos: {
    list: () => string[];
    summary: () => string[];
    byId: (id: string) => string[];
}
todos
: {
list: () => string[]list: () => ["todos"], summary: () => string[]summary: () => ["todos", "summary"], byId: (id: string) => string[]byId: (id: stringid: string) => ["todo", id: stringid], },
notes: {
    list: () => string[];
    byId: (id: string) => string[];
}
notes
: {
list: () => string[]list: () => ["notes"], byId: (id: string) => string[]byId: (id: stringid: string) => ["note", id: stringid], }, }) // Invalidating every todo query: one call, no omissions await tagInvalidation.invalidateTags({ tags: any[]tags: [const appTags: anyappTags.todos()], // expands to list + summary + every registered byId })
Calling the parent node — appTags.todos() — expands to all leaf tags that were registered underneath it. One call, zero omissions.

How parent expansion works

Zero-arg leaves are pre-collected

When defineTags() runs, it walks the tree and calls every zero-argument factory once to collect leaf values. These become the group's expansion set.

Parameterized leaves expand via the path registry

Factories with required arguments (like byId(id)) cannot be pre-collected. They are matched via TAG_PATH stamped on the value — queries that were tagged with any id under that path are included.

Partial invalidation is always possible

You never have to invalidate the whole group. Use appTags.todos.byId(id) to target one item and appTags.todos() for a bulk reset after a batch mutation.

Naming

Naming conventions

Good tag names are stable, domain-scoped nouns. They describe what data is cached, not what operation produces it.

Recommended

// Good — stable, domain-scoped, descriptive
export const const appTags: anyappTags = defineTags({
  
products: {
    list: () => string[];
    byId: (id: string) => string[];
    byCategory: (cat: string) => string[];
    featured: () => string[];
}
products
: {
list: () => string[]list: () => ["products"], byId: (id: string) => string[]byId: (id: stringid: string) => ["product", id: stringid], byCategory: (cat: string) => string[]byCategory:(cat: stringcat: string) => ["products", "category", cat: stringcat], featured: () => string[]featured: () => ["products", "featured"], },
cart: {
    items: (userId: string) => string[];
    totals: (userId: string) => string[];
}
cart
: {
items: (userId: string) => string[]items: (userId: stringuserId: string) => ["cart", userId: stringuserId, "items"], totals: (userId: string) => string[]totals: (userId: stringuserId: string) => ["cart", userId: stringuserId, "totals"], }, })

Avoid

// Avoid — generic, ambiguous names that collide across domains
export const const appTags: anyappTags = defineTags({
  list: () => string[]list:     () => ["list"],    // which list?
  data: () => string[]data:     () => ["data"],    // what data?
  byId: (id: string) => string[]byId:     (id: stringid: string) => [id: stringid],  // id of what?
  refresh: () => string[]refresh:  () => ["refresh"], // nothing is "refresh" — this is a verb
})

Use nouns, not verbs

Tags name data, not actions. todos.list not fetchTodos.

Prefix with the domain

cart.items, not just items. Prevents accidental cross-domain collisions.

Parameterize by identity

byId(id), byUserId(userId). The parameter IS the tag value for matching.

Keep leaves stable

A tag value is part of your API. Renaming a leaf breaks existing invalidations silently.

Multi-tenant & per-user

Scoping invalidation events

The scope parameter on invalidateTags routes the SSE event to a specific user, tenant, or custom channel. Clients only receive events that match their declared scope.

Global scope (default)

No scope parameter — the event reaches every connected client. Use for public data like product catalogs or announcements.

// Global scope (default) — all connected clients receive the event
await tagInvalidation.invalidateTags({
  tags: [appTags.products.list()],
  // No scope = { kind: "global" }
})

User & tenant scopes

Use kind: "user" for per-user private data (cart, preferences, drafts) and kind: "tenant" for SaaS workspaces. The SSE stream is filtered server-side — other clients never receive the event.

// User scope — only that user's SSE stream receives the event
await tagInvalidation.invalidateTags({
  tags: [appTags.cart.items(userId)],
  scope: { kind: "user", id: userId },
})

// Tenant scope — isolate a whole tenant
await tagInvalidation.invalidateTags({
  tags: [appTags.orders.list()],
  scope: { kind: "tenant", id: tenantId },
})

Declaring scope on the client

Pass scope to TagInvalidationProvider. The client subscribes to its scope's SSE channel and ignores events for other scopes.

// Client-side: declare the scope on the provider
// The client only receives events matching its scope
<TagInvalidationProvider
  client={invalidatorClient}
  queryClient={queryClient}
  router={router}
  scope={{ kind: "user", id: currentUser.id }}
>
  {children}
</TagInvalidationProvider>
The four built-in scope kinds are global, user, tenant, and custom. Use custom for anything that does not fit — for example, invalidating by region or by subscription tier.

Common pitfalls

Over- and under-invalidation

The most common mistakes with tags are invalidating too broadly (wasted network requests) or too narrowly (stale data that stays stale).

!

Over-invalidation

Blasting a wide parent tag for every mutation — even one that only affects a single item — causes every query in that group to refetch. On a high-traffic catalog with hundreds of products this is measurable wasted work.

// Over-invalidation: blasting a wide group when only a leaf changed
// Every single product query refetches on ANY product mutation —
// even if only one item's inventory changed.
await tagInvalidation.invalidateTags({
  tags: [appTags.products()],  // hits list, featured, byCategory(*), byId(*)
})

// Better: invalidate only what actually changed
await tagInvalidation.invalidateTags({
  tags: [
    appTags.products.byId(updatedProduct.id),
    appTags.products.list(),           // list totals/ordering may change
    appTags.products.featured(),       // if featured flag changed
  ],
})
Reserve the group tag appTags.products() for batch mutations (bulk price update, import) where most of the group is genuinely stale. For single-item mutations, prefer the leaf tag.
!

Under-invalidation

Updating one query's tag but forgetting that the same data appears in other views. A product price change should refresh both the detail page and the catalog listing.

// Under-invalidation: invalidating the leaf but forgetting the parent views
// The product detail updates, but the catalog grid still shows the old price.
await tagInvalidation.invalidateTags({
  tags: [appTags.products.byId(id)],  // detail page refreshes
  // Missing: appTags.products.list() — catalog grid keeps stale price
})

// Fix: include every view that shows the changed data
await tagInvalidation.invalidateTags({
  tags: [
    appTags.products.byId(id),
    appTags.products.list(),
  ],
})
Under-invalidation produces silent stale data — the worst kind, because users see inconsistent information without any visible error. Map every mutation to every affected query view before writing the invalidation call.

Decision guide: leaf vs group tag

Mutation typeRecommended tagsRationale
Update one itemleaf (byId) + affected list tagsMinimizes refetches while keeping all views consistent
Create a new itemlist tag (+ featured/summary if applicable)Lists change; existing item details are unaffected
Delete one itemleaf (byId) + list tagDetail is gone; lists and counts need to update
Bulk update / importgroup tag (parent node)Unknown subset changed — safest to refresh everything
Cross-resource mutationall affected domain group tagse.g. flash sale: invalidate products + promotions + cart

Query side

Attaching tags to queries

Three equivalent patterns — pick the one that fits your code style.

// Option A: inline meta.tags — explicit, always visible
useQuery({
  queryKey: ["products"],
  queryFn:  fetchProducts,
  meta: { tags: [appTags.products.list()] },
})

// Option B: withTags() helper — cleaner for shared query factories
const productsQuery = withTags(
  { queryKey: ["products"], queryFn: fetchProducts },
  [appTags.products.list()],
)
useQuery(productsQuery)

// Option C: withTags() with auto-derived queryKey (tags become the key)
const cartQuery = withTags(
  { queryFn: () => fetchCart(userId) },
  [appTags.cart.items(userId)],
)
// queryKey = [stableSerialize(appTags.cart.items(userId))]

A — inline meta

One-off queries or when queryKey is already set for DevTools

B — withTags() with explicit key

Shared query factories exported from a hooks file

C — withTags() with auto key

When you want the tags themselves to define cache identity

Special values

The catch-all tag

EVERY_INVALIDATION_TAG is a built-in sentinel automatically appended to every broadcast. Tag a query with it and it will refetch on any invalidation event, regardless of which tags were invalidated.

import { EVERY_INVALIDATION_TAG } from "@tanstack-tools/query-tags"

// This query refetches on EVERY invalidation event, regardless of tags
useQuery({
  queryKey: ["activity-feed"],
  queryFn:  fetchActivityFeed,
  meta: { tags: [EVERY_INVALIDATION_TAG] },
})
Use EVERY_INVALIDATION_TAG sparingly. It bypasses tag matching entirely — every mutation on any resource will trigger a refetch. Good for activity feeds or audit logs; harmful on data-heavy views.

Summary

Tag design checklist

  • 1Organize tags in a hierarchy — domains at the top, specific resources and IDs at the leaves.
  • 2Name tags as stable domain nouns, never action verbs.
  • 3Parameterize by identity (byId, byUserId) to enable leaf-level precision.
  • 4Use group tags (parent calls) only for bulk mutations that affect the entire domain.
  • 5Scope per-user and per-tenant data with the scope parameter to prevent cross-user pollution.
  • 6On every mutation, ask: which views show this data? Tag every one of them.
  • 7Avoid EVERY_INVALIDATION_TAG on expensive queries — it fires on every event.
  • 8Keep tag contracts stable — changing a leaf value is a breaking change.

Next step

See tags in action

The interactive demos show group invalidation, scope isolation, and cascading tags running against a live server.