tanstack-best-practices
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).
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 tRelated in Web Dev
generating-lwc-components
IncludedLightning Web Components with PICKLES methodology and 165-point scoring. Use this skill when the user creates or edits LWC components, builds wire service patterns, or writes Jest tests for LWC. TRIGGER when: user creates/edits LWC components, touches lwc/**/*.js, .html, .css, .js-meta.xml files, or asks about wire service, SLDS, or Jest LWC tests. DO NOT TRIGGER when: Apex classes (use generating-apex), Aura components, or Visualforce.
tanstack-query
IncludedManage server state in React with TanStack Query v5. Set up queries with useQuery, mutations with useMutation, configure QueryClient caching strategies, implement optimistic updates, and handle infinite scroll with useInfiniteQuery. Use when: setting up data fetching in React projects, migrating from v4 to v5, or fixing object syntax required errors, query callbacks removed issues, cacheTime renamed to gcTime, isPending vs isLoading confusion, keepPreviousData removed problems.
document-processor-api
IncludedProcess documents with Nutrient DWS. Use when the user wants to generate PDFs from HTML or URLs, convert Office/images/PDFs, assemble or split packets, OCR scans, extract text/tables/key-value pairs, redact PII, watermark, sign, fill forms, optimize PDFs, or produce compliance outputs like PDF/A or PDF/UA. Triggers include convert to PDF, merge these PDFs, OCR this scan, extract tables, redact PII, sign this PDF, make this PDF/A, or linearize for web delivery.
nutrient-document-processing
IncludedProcess documents with Nutrient DWS. Use when the user wants to generate PDFs from HTML or URLs, convert Office/images/PDFs, assemble or split packets, OCR scans, extract text/tables/key-value pairs, redact PII, watermark, sign, fill forms, optimize PDFs, or produce compliance outputs like PDF/A or PDF/UA. Triggers include convert to PDF, merge these PDFs, OCR this scan, extract tables, redact PII, sign this PDF, make this PDF/A, or linearize for web delivery.
tanstack-query
IncludedManage server state in React with TanStack Query v5. Covers useMutationState, simplified optimistic updates, throwOnError, network mode (offline/PWA), and infiniteQueryOptions. Use when setting up data fetching, fixing v4→v5 migration errors (object syntax, gcTime, isPending, keepPreviousData), or debugging SSR/hydration issues with streaming server components.
accelint-nextjs-best-practices
IncludedNext.js performance optimization and best practices. Use when writing Next.js code (App Router or Pages Router); implementing Server Components, Server Actions, or API routes; optimizing RSC serialization, data fetching, or server-side rendering; reviewing Next.js code for performance issues; fixing authentication in Server Actions; or implementing Suspense boundaries, parallel data fetching, or request deduplication.