Claude
Skills
Sign in
Back

tanstack-best-practices

Included with Lifetime
$97 forever

Best practices for building hook libraries with TanStack Query. Use when: (1) Writing useQuery/useMutation hooks that wrap async data-fetching functions, (2) Designing query key schemas and cache identity systems, (3) Building framework-agnostic query options factories, (4) Implementing cache invalidation patterns (invalidate vs remove vs setQueryData), (5) Wrapping TanStack Query in a multi-layered library (core actions to query options to framework hooks), (6) Handling non-serializable values (bigint, class instances) in query keys, (7) Bridging external stores (zustand, signals) with TanStack Query reactivity. Derived from wagmi's production architecture (React/Vue/Solid Ethereum hooks).

Web Dev

What this skill does


# TanStack Query Best Practices for Hook Libraries

## TanStack Query Core Concepts

### Cache Model

TanStack Query is a **key-value cache** for async data. Two components using the same query key share one cache entry and one network request (deduplication). If Component B mounts while Component A's fetch is in-flight, B subscribes to A's request — no duplicate.

### staleTime vs gcTime

These control the cache lifecycle:

- **`staleTime`** (default: 0) — how long data is "fresh" after fetching. While fresh, TQ serves from cache without refetching. Once stale, TQ refetches in the background on mount/focus (stale-while-revalidate: user sees old data instantly, then re-renders with fresh data).
- **`gcTime`** (default: 5 min) — how long unused cache entries survive after all observing components unmount. After gcTime, the entry is garbage-collected.

Timeline: `fetch -> fresh (staleTime) -> stale -> component unmounts -> gcTime expires -> entry deleted`

If a component remounts within gcTime with stale data: instant cached data + background refetch.
If a component remounts after gcTime: full loading state + fresh fetch.

Tune per-query: `gcTime: 0` for block numbers (change every ~12s, no point caching). `staleTime: Infinity` for immutable data (manual refetch only).

### Queries vs Mutations

- **Queries** (`useQuery`): declarative reads. Run on mount, cached by key, auto-refetch when stale. For GET/read operations.
- **Mutations** (`useMutation`): imperative writes. Run only when `mutate()`/`mutateAsync()` is called. Not cached. For POST/write operations.

`mutate()` is fire-and-forget (errors go to the `error` state). `mutateAsync()` returns a Promise you can await/catch.

## Three-Layer Architecture

### Why Three Layers

The key insight: **Layer 2 (query options factory) is framework-agnostic**. It lives in the core package and produces TanStack Query config objects that React, Vue, and Solid all consume identically. Adding a new framework adapter is ~4 lines of hook code. Each layer is independently testable and usable (Layer 1 works from Node.js/CLI, Layer 2 works with any TanStack Query adapter).

```
Layer 1: Core Action     — pure async function, no caching awareness
Layer 2: Query Options   — framework-agnostic TanStack Query config factory
Layer 3: Framework Hook  — thin wrapper (React/Vue/Solid), ~4 lines of real logic
```

### Layer 1: Core Action

```ts
// actions/getBalance.ts
export async function getBalance(config: Config, params: GetBalanceParams): Promise<Balance> {
  const client = config.getClient({ chainId: params.chainId })
  return client.fetchBalance(params.address)
}
```

Pure `(config, params) => Promise<Result>`. No TanStack Query imports. No framework imports.

### Layer 2: Query Options Factory

```ts
// query/getBalance.ts
export function getBalanceQueryOptions(config, options) {
  return {
    ...options.query,                    // (A) user overrides spread FIRST
    enabled: Boolean(                    // (B) then factory sets these — overrides user's
      options.address && (options.query?.enabled ?? true)
    ),
    queryKey: getBalanceQueryKey(options),  // (B) can't be overridden
    queryFn: async (context) => {
      const [, { scopeKey: _, ...params }] = context.queryKey  // extract from KEY
      return getBalance(config, params)
    },
  }
}
```

**Spread order matters:** `...options.query` is spread first (A), then `enabled`/`queryKey`/`queryFn` are set after (B). JS object spread means later properties overwrite earlier ones, so the factory's critical properties always win even if the user passes `query: { queryFn: ... }`.

**Params from queryKey, not closure:** The `queryFn` extracts parameters from `context.queryKey`, not from the surrounding scope. This guarantees cache consistency — the function fetches for exactly the parameters that determined which cache entry to use. Without this, a stale closure could fetch address `0xdef` while the cache entry is keyed by `0xabc`.

**Auto-gating via `enabled`:** Required params are AND-ed together. If `address` is undefined (e.g., wallet not connected), the query sits idle — no network request, no error. The moment `address` becomes defined, TanStack Query fires the fetch. Users never need to write conditional guards like `useBalance(address ? { address } : { enabled: false })`.

For multiple required params: `Boolean(address && abi && functionName && (query?.enabled ?? true))`.

### Layer 3: Framework Hook

```ts
// hooks/useBalance.ts
export function useBalance(parameters = {}) {
  const config = useConfig(parameters)
  const chainId = useChainId({ config })
  const options = getBalanceQueryOptions(config, {
    ...parameters,
    chainId: parameters.chainId ?? chainId,  // default to current chain from context
  })
  return useQuery({ ...options, queryKeyHashFn: hashFn })
}
```

Four steps: get config from context, get reactive state (chainId), build query options, call useQuery. Pass custom `queryKeyHashFn` if keys contain non-serializable values (bigint).

## Query Key Design

### Shape: `[label, filteredParams]`

Always a 2-element tuple. The label identifies the query type, the params object determines cache identity:
```ts
['balance', { address: '0xabc', chainId: 1 }]
['readContract', { address: '0xabc', functionName: 'balanceOf', args: ['0xdef'] }]
```

### filterQueryOptions — Cache Identity Filter

**Principle:** Only params that change *what data* is fetched belong in the key. Params that change *how the cache behaves* do not.

```ts
export function filterQueryOptions(options) {
  const {
    // TanStack Query behavioral options — NOT data identity
    gcTime, staleTime, enabled, select, refetchInterval, queryFn, queryKey,
    // Library internals — NOT data identity
    config, abi, connector, query, watch,
    ...rest  // only this survives into the key
  } = options
  if (connector) return { connectorUid: connector.uid, ...rest }
  return rest
}
```

Without this, two components with `useBalance({ address, query: { refetchInterval: 10_000 } })` and `useBalance({ address })` would have **different cache entries** despite wanting the same data.

**Why strip ABI:** ABIs can be hundreds of entries — expensive to hash, and redundant for cache identity since `address + functionName + args` already uniquely identify the call. The ABI is still used inside `queryFn` via `options.abi` (from closure), not from the key.

**Why replace connector with connectorUid:** Connector objects (class instances) aren't serializable. Replace with a stable string ID so two queries for the same connector share a cache entry.

### hashFn — BigInt and Key Ordering

Standard `JSON.stringify` fails on bigints (`TypeError`) and produces different strings for `{ a: 1, b: 2 }` vs `{ b: 2, a: 1 }` (cache miss for identical data).

```ts
export function hashFn(queryKey: QueryKey): string {
  return JSON.stringify(queryKey, (_, value) => {
    if (isPlainObject(value))
      return Object.keys(value).sort()
        .reduce((result, key) => { result[key] = value[key]; return result }, {})
    if (typeof value === 'bigint') return value.toString()
    return value
  })
}
```

Pass as `queryKeyHashFn` to `useQuery`. Guarantees deterministic hashing regardless of property insertion order and bigint values.

### scopeKey — Manual Cache Isolation

Users add a `scopeKey` string for separate cache entries with identical params (e.g., same balance displayed in header vs dashboard with different refresh rates). Included in key for identity, stripped in queryFn (not a data-fetching param):

```ts
const [, { scopeKey: _, ...params }] = context.queryKey
```

## Controlled Override Surface

### QueryParameter Type

```ts
type QueryParameter<...> = {
  query?: Omit<QueryOptions, 'queryKey' | 'queryFn'> | undefined
}
```

Users pass `{ query: { staleTime: 60_000, gcTime: 0, select: (d) => d.value } }`. They can tune cache behavior but can't override `queryKey` or `queryFn` — those are owned by t

Related in Web Dev