From spaghetti useQuery to clean architecture
There's a moment in every TanStack Query app when something breaks. It started with an innocent useQuery inside a component. Six months later you've got the same query duplicated in four places, query keys as raw strings everywhere, and when you fire a mutation you have no clue what to invalidate without grep'ing the whole repo.
That's the tanstack-ocalypse. And it's the natural consequence of skipping the queryOptions + Query Key Factory combo.
In this post:
- What TanStack Query actually is (spoiler: not a fetcher)
- Why putting
useQuerydirectly in components doesn't scale - How
queryOptionssaves your typing and your sanity - Why Query Key Factory is the missing piece in 90% of projects
- The full combo with real code you can copy
Let's go.
First: TanStack Query is NOT a fetcher
Drop the idea that TanStack Query is "a library for doing fetch." It's a server state manager. And that distinction is everything.
There are two types of state in any app:
Client state lives in your app, sync, you own it. Modal open? Tab active? What the user typed? That's useState territory.
Server state lives somewhere else (a remote API), async, you don't own it, can go stale, and other users can mutate it without you knowing.
For years we treated server state like client state: useState + useEffect + fetch. It works, but it's gross. TanStack Query was built specifically for the second category, and it gives you cache, dedup, background refetch, retries, and stale-while-revalidate out of the box.
The problem
The first version always looks innocent:
function UserList() {const { data, isLoading } = useQuery({queryKey: ["users"],queryFn: () => fetch("/api/users").then((r) => r.json()),staleTime: 60 * 1000,});// ...}
Six months later, this is your codebase:
// UserList.tsxuseQuery({ queryKey: ['users'], queryFn: ... });// UserListSidebar.tsx (different staleTime, why? nobody knows)useQuery({ queryKey: ['users'], queryFn: ..., staleTime: 30000 });// UserCount.tsx (someone copy-pasted with a typo, lol)useQuery({ queryKey: ['user'], queryFn: ... });// useCreateUser mutationonSuccess: () => {queryClient.invalidateQueries({ queryKey: ['users'] });queryClient.invalidateQueries({ queryKey: ['user'] }); // had to add this bc of the typo}
It's not "bad code." The architecture was wrong from line one.
Enter queryOptions
queryOptions is a helper from TanStack Query (since v5) that does literally nothing at runtime:
function queryOptions(options) {return options; // yep, returns the same object you passed}
Then why does it exist? For TypeScript. It's a typed identity function that gives TS the hints to do type inference across the entire TanStack Query API. Same pattern as Vite's defineConfig or Tailwind's createConfig.
The shift looks small but the implications are huge. Instead of writing useQuery inline, you extract the config into a reusable function:
// features/users/queries.tsimport { queryOptions } from "@tanstack/react-query";export const userListQueryOptions = () =>queryOptions({queryKey: ["users"] as const,queryFn: () => fetchUsers(),staleTime: 60 * 1000,});
And now you reuse that one definition across the entire TanStack Query API:
useQuery(userListQueryOptions());useSuspenseQuery(userListQueryOptions());queryClient.prefetchQuery(userListQueryOptions());queryClient.setQueryData(userListQueryOptions().queryKey, newData); // typed!queryClient.invalidateQueries({ queryKey: userListQueryOptions().queryKey });
The queryKey and the data type are now linked forever via inference. Change the queryFn shape and TypeScript yells at every consumer. That's the kind of refactor safety you want.
Level up: Query Key Factory
queryOptions alone is solid. Combined with a Query Key Factory, it's a different sport.
Instead of having keys scattered as strings, you build a typed hierarchy:
// features/users/queries.tsexport const userKeys = {all: ["users"] as const,lists: () => [...userKeys.all, "list"] as const,list: (filters: string) => [...userKeys.lists(), { filters }] as const,details: () => [...userKeys.all, "detail"] as const,detail: (id: number) => [...userKeys.details(), id] as const,};
Looks dense at first, but the tree it builds is gold:
['users'] ← userKeys.all├─ ['users', 'list'] ← userKeys.lists()│ ├─ ['users', 'list', { filters: 'active' }]│ └─ ['users', 'list', { filters: 'inactive' }]└─ ['users', 'detail'] ← userKeys.details()├─ ['users', 'detail', 1]└─ ['users', 'detail', 2]
Why does this matter? Because TanStack Query does partial matching on key arrays. Translation: you can invalidate at any level of the tree:
// new user created → refresh everything users-relatedqueryClient.invalidateQueries({ queryKey: userKeys.all });// edited user 1 → only that detail and the listsqueryClient.invalidateQueries({ queryKey: userKeys.detail(1) });queryClient.invalidateQueries({ queryKey: userKeys.lists() });// a list filter changed → only invalidate the listsqueryClient.invalidateQueries({ queryKey: userKeys.lists() });
Surgical control. No raw strings flying around.
If it helps, picture the cache as a filing cabinet: the key is the path to a drawer, and invalidating opens every drawer under the path you give it:
The full combo in action
Now we put both worlds together. This is the canonical pattern:
// features/users/queries.tsimport { queryOptions } from "@tanstack/react-query";import { fetchUsers, fetchUser } from "./api";// 1. Query Key Factoryexport const userKeys = {all: ["users"] as const,lists: () => [...userKeys.all, "list"] as const,list: (filters: string) => [...userKeys.lists(), { filters }] as const,details: () => [...userKeys.all, "detail"] as const,detail: (id: number) => [...userKeys.details(), id] as const,};// 2. Query Options that reuse the keysexport const userQueries = {list: (filters: string) =>queryOptions({queryKey: userKeys.list(filters),queryFn: () => fetchUsers(filters),}),detail: (id: number) =>queryOptions({queryKey: userKeys.detail(id),queryFn: () => fetchUser(id),}),};
And here's the magic — every layer of your app uses the same building blocks:
// Componentfunction UserList() {const { data } = useQuery(userQueries.list("active"));return (<ul>{data?.map((u) => (<li key={u.id}>{u.name}</li>))}</ul>);}function UserDetail({ id }: { id: number }) {const { data } = useQuery(userQueries.detail(id));return <h1>{data?.name}</h1>;}// Loader (TanStack Router) — fetches before component mountsloader: ({ params, context }) =>context.queryClient.ensureQueryData(userQueries.detail(Number(params.id)));// Mutation with surgical invalidationconst updateUser = useMutation({mutationFn: (user) => api.updateUser(user),onSuccess: (_, variables) => {queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });queryClient.invalidateQueries({ queryKey: userKeys.lists() });},});// Prefetch on link hover — feels instant<Linkto={`/users/${id}`}onMouseEnter={() => queryClient.prefetchQuery(userQueries.detail(id))}>See detail</Link>;
Look at the consistency. Same pattern, every layer. Any dev landing in the project gets it instantly.
One detail worth highlighting: in this code, everything starting with use (useQuery, useMutation) comes directly from the library. userQueries and userKeys are just plain objects you defined — no custom hooks involved. That's the whole point: you're not wrapping the library, you're feeding it well-shaped config.
What you actually win
- Single source of truth per server entity. One file, one place to change things.
- End-to-end type safety: key and data shape connected forever via inference.
- Hierarchical invalidation: invalidate broad or surgical, your call.
- Co-location: everything users-related lives in
features/users/. Delete the folder, delete the feature. Done. - Refactor-friendly: change a
queryFnand TypeScript catches every consumer. - AI-friendly: Cursor and Claude grok this structure way better than scattered hooks. In 2026 that matters more than you think.
When NOT to use it
It's not a silver bullet:
- Weekend POCs with three components. Overkill, just inline it.
- One-off queries that'll never be invalidated and live in a single place. Inline is fine.
Anything beyond that — features with mutations, multiple consumers, anything that's expected to grow — you want this from day one. The cost of setting it up is one file. The cost of not setting it up is a refactor in six months.
Wrap-up
The shift isn't technical, it's mental. You stop thinking "I'll call useQuery wherever I need data" and start thinking "I declare my server entities once and reuse them everywhere."
Once that click happens, mutations stop being scary, refactors stop being risky, and the codebase ages well instead of collapsing under its own weight.
Don't optimize for today. Optimize for the version of you reading this code in six months.
De useQuery spaghetti a una arquitectura limpia
En toda app con TanStack Query llega un momento en que algo se rompe. Arrancó con un useQuery inocente adentro de un componente. Seis meses después tenés la misma query duplicada en cuatro lugares, query keys como strings sueltos por todos lados, y cuando disparás una mutation no tenés idea de qué invalidar sin hacer grep en todo el repo.
Eso es el tanstack-ocalypse. Y es la consecuencia natural de saltearse el combo queryOptions + Query Key Factory.
En este post:
- Qué es realmente TanStack Query (spoiler: no es un fetcher)
- Por qué meter
useQuerydirecto en los componentes no escala - Cómo
queryOptionste salva el tipado y la cordura - Por qué Query Key Factory es la pieza que falta en el 90% de los proyectos
- El combo completo con código real que podés copiar
Vamos.
Primero: TanStack Query NO es un fetcher
Sacate de la cabeza que TanStack Query es "una librería para hacer fetch". Es un gestor de server state. Y esa distinción lo es todo.
En cualquier app hay dos tipos de estado:
Client state vive en tu app, es sincrónico y es tuyo. ¿El modal está abierto? ¿Qué tab está activa? ¿Qué escribió el usuario? Eso es territorio de useState.
Server state vive en otro lado (una API remota), es asincrónico, no es tuyo, puede quedar desactualizado y otros usuarios lo pueden modificar sin que te enteres.
Durante años tratamos al server state como si fuera client state: useState + useEffect + fetch. Funciona, pero es un asco. TanStack Query se hizo específicamente para la segunda categoría, y de entrada te da cache, deduplicación, refetch en background, reintentos y stale-while-revalidate.
El problema
La primera versión siempre parece inocente:
function UserList() {const { data, isLoading } = useQuery({queryKey: ["users"],queryFn: () => fetch("/api/users").then((r) => r.json()),staleTime: 60 * 1000,});// ...}
Seis meses después, tu codebase es esto:
// UserList.tsxuseQuery({ queryKey: ['users'], queryFn: ... });// UserListSidebar.tsx (different staleTime, why? nobody knows)useQuery({ queryKey: ['users'], queryFn: ..., staleTime: 30000 });// UserCount.tsx (someone copy-pasted with a typo, lol)useQuery({ queryKey: ['user'], queryFn: ... });// useCreateUser mutationonSuccess: () => {queryClient.invalidateQueries({ queryKey: ['users'] });queryClient.invalidateQueries({ queryKey: ['user'] }); // had to add this bc of the typo}
No es "código malo". La arquitectura estuvo mal desde la primera línea.
Entra queryOptions
queryOptions es un helper de TanStack Query (desde la v5) que en runtime no hace literalmente nada:
function queryOptions(options) {return options; // yep, returns the same object you passed}
¿Entonces para qué existe? Para TypeScript. Es una función identidad tipada que le da a TS las pistas para inferir tipos a lo largo de toda la API de TanStack Query. El mismo patrón que el defineConfig de Vite o el createConfig de Tailwind.
El cambio parece chico, pero las implicancias son enormes. En vez de escribir useQuery inline, extraés la config a una función reutilizable:
// features/users/queries.tsimport { queryOptions } from "@tanstack/react-query";export const userListQueryOptions = () =>queryOptions({queryKey: ["users"] as const,queryFn: () => fetchUsers(),staleTime: 60 * 1000,});
Y ahora reutilizás esa única definición en toda la API de TanStack Query:
useQuery(userListQueryOptions());useSuspenseQuery(userListQueryOptions());queryClient.prefetchQuery(userListQueryOptions());queryClient.setQueryData(userListQueryOptions().queryKey, newData); // typed!queryClient.invalidateQueries({ queryKey: userListQueryOptions().queryKey });
La queryKey y el tipo de los datos quedan atados para siempre por inferencia. Cambiás la forma de lo que devuelve el queryFn y TypeScript te grita en cada consumidor. Esa es la seguridad para refactorizar que querés.
Subiendo de nivel: Query Key Factory
queryOptions solo ya está muy bien. Combinado con una Query Key Factory, es otro deporte.
En lugar de tener keys desparramadas como strings, armás una jerarquía tipada:
// features/users/queries.tsexport const userKeys = {all: ["users"] as const,lists: () => [...userKeys.all, "list"] as const,list: (filters: string) => [...userKeys.lists(), { filters }] as const,details: () => [...userKeys.all, "detail"] as const,detail: (id: number) => [...userKeys.details(), id] as const,};
Al principio parece denso, pero el árbol que arma vale oro:
['users'] ← userKeys.all├─ ['users', 'list'] ← userKeys.lists()│ ├─ ['users', 'list', { filters: 'active' }]│ └─ ['users', 'list', { filters: 'inactive' }]└─ ['users', 'detail'] ← userKeys.details()├─ ['users', 'detail', 1]└─ ['users', 'detail', 2]
¿Por qué importa? Porque TanStack Query hace matching parcial sobre los arrays de keys. Traducción: podés invalidar en cualquier nivel del árbol:
// new user created → refresh everything users-relatedqueryClient.invalidateQueries({ queryKey: userKeys.all });// edited user 1 → only that detail and the listsqueryClient.invalidateQueries({ queryKey: userKeys.detail(1) });queryClient.invalidateQueries({ queryKey: userKeys.lists() });// a list filter changed → only invalidate the listsqueryClient.invalidateQueries({ queryKey: userKeys.lists() });
Control quirúrgico. Nada de strings sueltos volando por ahí.
Si ayuda, pensá la caché como un fichero: la key es el camino hasta un cajón, e invalidar abre todos los cajones que cuelgan del camino que le pasás:
El combo completo en acción
Ahora juntamos los dos mundos. Este es el patrón canónico:
// features/users/queries.tsimport { queryOptions } from "@tanstack/react-query";import { fetchUsers, fetchUser } from "./api";// 1. Query Key Factoryexport const userKeys = {all: ["users"] as const,lists: () => [...userKeys.all, "list"] as const,list: (filters: string) => [...userKeys.lists(), { filters }] as const,details: () => [...userKeys.all, "detail"] as const,detail: (id: number) => [...userKeys.details(), id] as const,};// 2. Query Options that reuse the keysexport const userQueries = {list: (filters: string) =>queryOptions({queryKey: userKeys.list(filters),queryFn: () => fetchUsers(filters),}),detail: (id: number) =>queryOptions({queryKey: userKeys.detail(id),queryFn: () => fetchUser(id),}),};
Y acá está la magia: cada capa de tu app usa las mismas piezas:
// Componentfunction UserList() {const { data } = useQuery(userQueries.list("active"));return (<ul>{data?.map((u) => (<li key={u.id}>{u.name}</li>))}</ul>);}function UserDetail({ id }: { id: number }) {const { data } = useQuery(userQueries.detail(id));return <h1>{data?.name}</h1>;}// Loader (TanStack Router) — fetches before component mountsloader: ({ params, context }) =>context.queryClient.ensureQueryData(userQueries.detail(Number(params.id)));// Mutation with surgical invalidationconst updateUser = useMutation({mutationFn: (user) => api.updateUser(user),onSuccess: (_, variables) => {queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });queryClient.invalidateQueries({ queryKey: userKeys.lists() });},});// Prefetch on link hover — feels instant<Linkto={`/users/${id}`}onMouseEnter={() => queryClient.prefetchQuery(userQueries.detail(id))}>See detail</Link>;
Fijate la consistencia. El mismo patrón en todas las capas. Cualquier dev que cae en el proyecto lo entiende al toque.
Un detalle que vale la pena remarcar: en este código, todo lo que empieza con use (useQuery, useMutation) viene directo de la librería. userQueries y userKeys son objetos planos que definiste vos, sin ningún custom hook de por medio. De eso se trata: no estás envolviendo la librería, la estás alimentando con config bien armada.
Lo que ganás de verdad
- Una única fuente de verdad por entidad del servidor. Un archivo, un solo lugar donde cambiar las cosas.
- Type safety de punta a punta: la key y la forma de los datos conectadas para siempre por inferencia.
- Invalidación jerárquica: invalidás amplio o quirúrgico, vos elegís.
- Co-location: todo lo relacionado a usuarios vive en
features/users/. Borrás la carpeta, borrás la feature. Listo. - Fácil de refactorizar: cambiás un
queryFny TypeScript agarra a cada consumidor. - Amigable con la IA: Cursor y Claude entienden esta estructura mucho mejor que hooks desparramados. En 2026 eso importa más de lo que pensás.
Cuándo NO usarlo
No es una bala de plata:
- POCs de fin de semana con tres componentes. Es overkill, ponelo inline.
- Queries sueltas que nunca se van a invalidar y viven en un solo lugar. Inline está bien.
Para todo lo demás (features con mutations, varios consumidores, cualquier cosa que se espera que crezca) esto lo querés desde el día uno. Armarlo te cuesta un archivo. No armarlo te cuesta un refactor dentro de seis meses.
Cierre
El cambio no es técnico, es mental. Dejás de pensar "voy a llamar a useQuery donde necesite datos" y empezás a pensar "declaro mis entidades del servidor una vez y las reutilizo en todos lados".
Una vez que hace click, las mutations dejan de dar miedo, los refactors dejan de ser riesgosos, y el codebase envejece bien en lugar de derrumbarse bajo su propio peso.
No optimices para hoy. Optimizá para la versión tuya que va a leer este código dentro de seis meses.