From personal-skills
Provides best practices for TanStack Query hook libraries including query key schemas, cache invalidation patterns, multi-layer architecture, and zustand bridging.
npx claudepluginhub enitrat/skill-issue --plugin personal-skillsThis skill uses the workspace's default tool permissions.
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.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
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.
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).
useQuery): declarative reads. Run on mount, cached by key, auto-refetch when stale. For GET/read operations.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.
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
// 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.
// 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)).
// 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).
[label, filteredParams]Always a 2-element tuple. The label identifies the query type, the params object determines cache identity:
['balance', { address: '0xabc', chainId: 1 }]
['readContract', { address: '0xabc', functionName: 'balanceOf', args: ['0xdef'] }]
Principle: Only params that change what data is fetched belong in the key. Params that change how the cache behaves do not.
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.
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).
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.
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):
const [, { scopeKey: _, ...params }] = context.queryKey
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 the factory to ensure cache consistency.
type MutationParameter<...> = {
mutation?: Omit<MutationOptions, 'mutationFn' | 'mutationKey' | 'throwOnError'> | undefined
}
Users get onSuccess, onError, onSettled, onMutate, retry, etc. Can't override mutationFn (would bypass the core action layer) or mutationKey.
// 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)enabled gating — mutations are imperative, triggered by user actionSome data streams continuously (block numbers via WebSocket). Combine a TanStack Query (for cache/dedup) with a subscription that pushes into the cache:
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.
See references/invalidation-patterns.md for detailed patterns with code examples.
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.
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:
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.
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.
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.
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 for detailed patterns covering:
expectTypeOf (generic inference, select transforms, callback types)| 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 |