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.tsx
useQuery({ 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 mutation
onSuccess: () => {
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.ts
import { 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.ts
export 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-related
queryClient.invalidateQueries({ queryKey: userKeys.all });
// edited user 1 → only that detail and the lists
queryClient.invalidateQueries({ queryKey: userKeys.detail(1) });
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
// a list filter changed → only invalidate the lists
queryClient.invalidateQueries({ queryKey: userKeys.lists() });Surgical control. No raw strings flying around.
The full combo in action
Now we put both worlds together. This is the canonical pattern:
// features/users/queries.ts
import { queryOptions } from "@tanstack/react-query";
import { fetchUsers, fetchUser } from "./api";
// 1. Query Key Factory
export 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 keys
export 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:
// Component
function 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 mounts
loader: ({ params, context }) =>
context.queryClient.ensureQueryData(userQueries.detail(Number(params.id)));
// Mutation with surgical invalidation
const 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
<Link
to={`/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.