Skip to main content

/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.