--- name: tanstack-best-practices description: "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)." --- # 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 { const client = config.getClient({ chainId: params.chainId }) return client.fetchBalance(params.address) } ``` Pure `(config, params) => Promise`. 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 | 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 the factory to ensure cache consistency. ### MutationParameter Type ```ts type MutationParameter<...> = { mutation?: Omit | undefined } ``` Users get `onSuccess`, `onError`, `onSettled`, `onMutate`, `retry`, etc. Can't override `mutationFn` (would bypass the core action layer) or `mutationKey`. ## Mutation Patterns ```ts // Layer 2: mutation options factory export function sendTransactionMutationOptions(config, options) { return { ...(options.mutation as any), mutationFn: (variables) => sendTransaction(config, variables), mutationKey: ['sendTransaction'], // static label, not for caching } } // Layer 3: React hook export function useSendTransaction(parameters = {}) { const config = useConfig(parameters) return useMutation(sendTransactionMutationOptions(config, parameters)) } ``` Key differences from queries: - `mutationKey` is a static label (exists for devtools/global callbacks, not cache identity) - `mutationFn` receives `variables` directly from the `mutate()` call (not from a key) - No `enabled` gating — mutations are imperative, triggered by user action ## The Watch Pattern — Push-to-Pull Bridge Some data streams continuously (block numbers via WebSocket). Combine a TanStack Query (for cache/dedup) with a subscription that pushes into the cache: ```ts function useBlockNumber(parameters) { const options = getBlockNumberQueryOptions(config, { ... }) const queryClient = useQueryClient() useWatchBlockNumber({ enabled: Boolean(watch), onBlockNumber(blockNumber) { queryClient.setQueryData(options.queryKey, blockNumber) // push into cache }, }) return useQuery(options) // reads from cache } ``` **Why:** The subscription already has the data — refetching via TanStack Query would be a redundant RPC call. `setQueryData` writes directly into the cache, TanStack Query handles re-rendering all subscribers. Users get real-time updates + cache deduplication + stale-while-revalidate, all in one hook. ## Cache Invalidation Patterns See [references/invalidation-patterns.md](references/invalidation-patterns.md) for detailed patterns with code examples. ## Bridging External Stores with TanStack Query ### Why Two Systems Use an external store (zustand, signals) for **app state** and TanStack Query for **server/async data**. They serve fundamentally different data patterns: | Dimension | External Store (zustand) | TanStack Query | |-----------|--------------------------|----------------| | Data source | Events (wallet connect/disconnect) | Async requests (RPC calls) | | Persistence | localStorage, survives refresh | In-memory with GC | | Staleness | Never stale — it's the truth | Frequently stale, needs refetching | | Deduplication | Not needed (single source) | Critical (many components, one fetch) | Trying to use TanStack Query for connection state would be awkward — connections aren't "fetched" data. Trying to use zustand for blockchain data would mean reimplementing caching, dedup, GC, and stale-while-revalidate. ### Bridge via useSyncExternalStore Use zustand in **vanilla mode** (`zustand/vanilla`) so the store is framework-agnostic (no React dependency in the core package). Bridge to React with `useSyncExternalStore`: ```ts const chainId = useSyncExternalStore( (onChange) => watchChainId(config, { onChange }), // subscribe to zustand slice () => getChainId(config), // read current value ) ``` When the store updates, React re-renders, hook params change, query keys change, TanStack Query refetches. Neither system knows about the other — React hooks are the glue. ### Property Access Tracking Optimization For hooks returning objects (like `useConnection` returning `{ address, connector, chainId }`), wrap the result with getter-based proxies that track which properties each component accesses. On the next state change, only compare tracked properties — if a component only uses `address` and only `chainId` changed, skip the re-render. ### Complete Event Flow External event (e.g., user switches chain in MetaMask): ``` Wallet extension emits chainChanged -> Connector catches it, emits 'change' event with new chainId -> Event handler updates zustand store (connection.chainId = 10) -> syncConnectedChain subscriber: global store.chainId = 10 -> useSyncExternalStore fires, useChainId returns 10 -> useBalance re-renders, query key becomes ['balance', { address, chainId: 10 }] -> TanStack Query: new key, cache miss, calls queryFn -> getBalance(config, { address, chainId: 10 }) via new viem client -> UI updates with new chain's balance ``` No invalidation code needed — the key change naturally triggers the fetch. ## Testing TanStack Query Hook Libraries Test in four layers, each catching different bugs: ``` Layer 4: Type Tests (*.test-d.ts) — compile-time type assertions (instant) Layer 3: Hook Integration (*.test.tsx) — full render + fetch + assert lifecycle (~5-10s) Layer 2: Query Options (query/*.test.ts) — factory output: key shape, enabled logic (ms) Layer 1: Core Actions (actions/*.test.ts) — raw async functions against real backend (~1s) ``` **Key philosophy:** Test against real backends (not mocked RPC responses) for integration confidence. Mock only the wallet/auth connection layer — the data-fetching layer hits real services. See [references/testing-patterns.md](references/testing-patterns.md) for detailed patterns covering: - Test infrastructure setup (backend instances, mock connectors, worker isolation) - Query hook test pattern (render, wait, snapshot) - Mutation hook test pattern (connect, render, mutate, assert) - Watch/subscription test pattern (render, trigger event, verify reactive update) - Query options factory tests (synchronous output verification) - Type-level tests with `expectTypeOf` (generic inference, select transforms, callback types) ## Anti-Patterns | Anti-Pattern | Why It's Wrong | Do This Instead | |---|---|---| | Close over params in `queryFn` | Cache entry keyed by X but fetching Y (stale closure) | Extract params from `context.queryKey` | | Include `staleTime`/`select` in query key | Two components wanting same data get separate cache entries | Use `filterQueryOptions` to strip behavioral options | | Serialize large objects (ABIs) into keys | Expensive to hash, redundant for identity | Strip from key, pass via closure to queryFn | | `invalidateQueries` on disconnect | Triggers a refetch that fails (no data source) | `removeQueries` when data source is gone | | Framework-specific query options | Kills reusability across React/Vue/Solid | Keep Layer 2 framework-agnostic | | Manual `enabled` guards in components | Verbose, error-prone, duplicated across call sites | Auto-gate in the query options factory | | `useQuery` for write operations | Fires on mount, caches results, auto-refetches | `useMutation` for imperative writes |