/writing
queryFn vs select: One Transform, Two Consumers
· 3 min read
TanStack Query gives you two places to transform a response. You can do it in the queryFn, before the data enters the cache. Or in select, as each observer reads it out.
An observer is a mounted useQuery. Hold onto that, because it turns out to be the whole story.
The transforms are the boring kind every API layer collects: flatten an envelope, parse date strings into dates, rename fields into the language the app uses. The two spots look interchangeable. They are not, and you only find out when something other than a component reads the cache.
The textbook move
A data-fetching audit on a dashboard I work on moved the transforms out of queryFn and into select. That is the documented default, for good reasons. The cache keeps the raw server shape, transforms run per observer with structural sharing, and two components can derive different views of the same entry without refetching.
The migration shipped. It was fine for about a month.
The consumer select never meets
The dashboard prefetches on navigation. Route loaders call ensureQueryData with the same query options the components use, and select never runs for those. Loaders, prefetches and any imperative getQueryData read all get the raw shape, while mounted components get the transformed one. Same query key, two shapes, depending on who is asking.
That is a quiet lie in the type system. Anything typed against the query’s data that runs in a loader is typed against a value it will never see. It works until a loader inspects the data it just fetched, say a redirect that keys on a field only the transform produces, and then it breaks a long way from the cause.
Compose into queryFn
So the transform moved again, into the queryFn, through a small helper applied by the query option builders. Those builders are the per-domain factories that produce each query’s options, and both components and loaders use them. If a builder declares a transform, the helper wraps the fetch. The transformed shape is what lands in the cache, and everyone reads the same thing.
select stays for what it is genuinely good at: deriving something view-local, like a component subscribing to one field of a bigger result.
The cost is real. The raw response is gone from the cache, and devtools show the transformed shape. I think that trade is right here, because these transforms are canonical cleanup rather than presentation.
The change was small because the builders already existed. Every domain in the dashboard produces its query options from one factory, so moving the transform meant changing where the factory applies it, not touching call sites. Had each query been assembled by hand, this would have been a migration instead of an edit.
What I learned
Pick the transform point by listing who reads the cache, not by reaching for the documented default. If observers are the only readers, select is correct. The moment loaders prefetch, the cache is a shared contract, and a contract belongs upstream of everyone who depends on it.
More writing
A Practical Prefetching Strategy with TanStack Query and Router
How I implemented intent-based preloading, route-level prefetching, and pagination prefetching to make a dashboard feel instant.
· 4 min read
Proxy Patterns for Multi-API Dashboards
How I designed a proxy layer to unify multiple APIs with different auth, error shapes, and data models behind one type-safe surface in a React dashboard.
· 3 min read
Deploying Swagger UI for Bacen's Pix API
A pull request to the Brazilian central bank that was closed without ever being merged, and shipped anyway. Rendering an OpenAPI spec as docs, from CI.
· 2 min read