Clean Frontend Architecture: How to Use React Query Without Breaking Your Use Cases
- The mistake: using React Query everywhere
- React Query belongs in the Presentation layer
- The generic adapter
- Do not expose the whole React Query API
- Components should use feature-specific hooks
- Query keys should come from use cases
- Invalidation should also use use-case language
- Mutations follow the same rule
- Why this matters during migrations
- React Query in clean architecture: the right boundaries
- What you get when React Query stays at the edge
React Query solves real problems: caching, background refetching, invalidation, retries. But in many codebases, it ends up solving them by becoming the architecture itself. Not just supporting it.
In a previous article, we talked about organizing frontend code by business domains using clean architecture: Domain, Application, Infrastructure, and Presentation.
The main idea was simple: business rules should not depend on React, APIs, databases, or UI libraries. Your use cases should describe what the application does, while the outer layers decide how that work is triggered, fetched, cached, or displayed.
That raises a real question: where does React Query fit? React Query should not replace your use cases. It should adapt your use cases to React’s async rendering needs.
The mistake: using React Query everywhere
A common approach is to call useQuery directly inside components:
const { data, isLoading } = useQuery({
queryKey: ['furniture', roomId],
queryFn: () => fetch(`/api/rooms/${roomId}/furniture`).then(r => r.json()),
})
It works… at first, but this small example already mixes several responsibilities:
- The component knows how data is fetched.
- The query key is invented inside the component.
- React Query’s API leaks directly into the UI.
- Business intent is hidden behind technical details.
The component is no longer saying: “I need the furniture for this room.” It is saying: “I know the cache key, the endpoint, the fetching strategy, and the async-state library.” That is too much responsibility for a component.
React Query belongs in the Presentation layer
In a clean frontend architecture, the use case belongs to the Application layer. A use case should look like this:
export type UseCase<Params, Data> = {
id: string
execute: (params: Params) => Promise<Data>
}
It has an identity and an execution function. It does not know about React. It does not know about React Query. It does not know whether the result will be shown in a page, cached, prefetched, or invalidated.
React Query belongs outside of that. More specifically, React Query belongs inside Presentation adapter hooks: hooks whose job is to adapt use cases to React rendering concerns.
Component
-> Feature query hook
-> Generic query adapter
-> React Query
-> Use case
-> Repository
The use case remains clean. React Query stays at the edge.
The generic adapter
Instead of using useQuery directly everywhere, we can create a generic adapter:
export const useCustomQuery = ({ useCase, params, options }) => {
const query = useQuery({
queryKey: queryKeyFn({ useCase, params }),
queryFn: () => useCase.execute(params),
enabled: !options?.disabled,
})
return {
data: query.data,
error: query.error,
isLoading: query.isPending,
isFetching: query.isFetching,
}
}
This hook is not just a wrapper; It is a boundary. The application decides which concepts it wants to expose: data, error, isLoading, isFetching. Those names are part of our frontend language. They are not there because React Query happens to name things that way today.
If React Query changes isPending, isLoading, or any other internal state in a future version, the components should not care. The adapter changes, but the rest of the app keeps using the same contract. That is the architectural benefit.
Do not expose the whole React Query API
A common mistake is to create a wrapper but still pass every React Query option through it. That does not solve the coupling problem. It only hides it behind another file. The adapter should expose only the options your architecture wants to support:
type QueryOptions<Params, Data> = {
disabled?: boolean
onSuccess?: (data: Data, params: Params) => void
onError?: (error: DomainError, params: Params) => void
}
These names are also your language. Maybe React Query calls it enabled. Maybe another library calls it skip. Your app does not need to follow the library’s vocabulary because the adapter maps your vocabulary to the library. The query adapts to your architecture, not the other way around.
Components should use feature-specific hooks
The generic adapter is useful, but components should usually not call it directly.
Instead of this:
useCustomQuery({
useCase: getFurnitureUseCase,
params: { roomId },
})
Prefer this:
const { data, isLoading, isFetching } = useGetFurnitureQuery({ roomId })
The feature hook should expose the use case to the component — but it should not import repositories itself. Repositories are Infrastructure, and if the presentation hook imports them directly, Presentation ends up depending on Infrastructure, quietly breaking the dependency rule we set out to protect.
So here is the looser version you will often see:
// Presentation importing Infrastructure directly
import { furnitureRepositories } from '@/infrastructure/furniture'
const getFurnitureUseCase =
livingRoomUseCases.getFurniture(furnitureRepositories)
export const useGetFurnitureQuery = ({ roomId }) => {
return useCustomQuery({ useCase: getFurnitureUseCase, params: { roomId } })
}
To be clear, this version is not wrong; it’s a looser take on the architecture, and it’s perfectly acceptable in plenty of projects, as long as you choose it knowingly.
What it buys you: less ceremony. No provider, no DI container, the use case is a simple module-level singleton, everything colocated and easy to read. For a small or single-implementation app, the full composition root may not earn its keep.
What you give up are two things.
- First, testability: the use case is bound to the real
furnitureRepositoriesat import time, so to test the hook you have to module-mock the infrastructure (jest.mock('@/infrastructure/furniture')) instead of simply handing it a fake repository. - Second, swappability: the module-level use case is a singleton wired to one repository, so you can’t easily switch implementations — mock vs. real, per-environment, multi-tenant, offline vs. API — without rewiring.
So, a fair rule of thumb: the direct-import version is fine when you have a single repository implementation, don’t need to swap it, and are comfortable mocking at the module boundary in tests. The moment any of those stops being true, the looseness starts costing you, and that’s where the composition root pays off.
The stricter fix is a composition root: a single wiring point where use cases are built with their repositories. The presentation hook then receives an already-composed use case instead of constructing one.
The composition root does not belong to any of the four layers. It lives at the application’s entry point — the bootstrap/startup code that assembles the app (for example, src/composition/, next to where you mount React) — because it is the one place that is allowed to see every layer at once. That is exactly why it can connect Infrastructure to Application without either layer depending on the other.
// src/composition/livingRoom.ts
// Lives outside the layers, at the app's wiring level —
// the only place Infrastructure meets Application.
export const livingRoomComposition = {
getFurniture: livingRoomUseCases.getFurniture(furnitureRepositories),
// ...other wired use cases
}
The feature hook just reads the use case it needs from there:
export const useGetFurnitureQuery = ({ roomId }) => {
return useCustomQuery({
useCase: livingRoomComposition.getFurniture,
params: { roomId },
})
}
Now Presentation never imports a repository directly. The repository is injected once, at the composition root, so the hook only ever touches the use case’s contract. How that composition root is built and provided to the app is a topic on its own, so I’ll cover it in detail in a separate article.
The component then asks only for the data it needs:
const { data, isLoading } = useGetFurnitureQuery({ roomId })
It does not know about repositories, query keys, or React Query options. That is the right level of abstraction for a component.
Query keys should come from use cases
React Query needs query keys. But in clean architecture, those keys should not be random strings created by components. The query key should describe the use case call, not the component that needs the data.
queryKeyFn({ useCase, params })
For example:
queryKey: [useCase.id, JSON.stringify(params)]
This means the cached data is identified by:
- The use case being executed
- The params used to execute it
So instead of thinking:
['furniture-page', roomId]
We think:
[getFurnitureUseCase.id, { roomId }]
That is a subtle but important difference: the cache now follows application behavior, and it is no longer tied to a page or component.
Invalidation should also use use-case language
The same idea applies to mutations. After creating a piece of furniture, we usually need to refresh the list of furniture for the room. The mutation should not invalidate a random string key:
queryClient.invalidateQueries(['furniture'])
It should invalidate the cached result of the affected read use case:
queryClient.invalidateQueries({
queryKey: queryKeyFn({
useCase: getFurnitureUseCase,
params: { roomId },
}),
})
The flow becomes clear:
createFurniture succeeds
-> getFurniture is now stale for this room
-> invalidate getFurnitureUseCase + params
React Query still performs the cache invalidation, but the code describes the application relationship. That is the point.
Mutations follow the same rule
The same pattern works for commands:
export const useCreateFurnitureMutation = () => {
return useCustomMutation({
useCase: createFurnitureUseCase,
options: {
onSuccess: (_, params) => {
invalidateGetFurnitureQuery({ roomId: params.roomId })
},
},
})
}
The component receives a stable command API:
const { execute, isLoading } = useCreateFurnitureMutation()
Again, isLoading is our app’s contract. Internally it might map to isPending, isMutating, or something else in the future.
The component should not care.
Why this matters during migrations
This pattern becomes especially valuable when libraries evolve. In one project, React Query was used directly across many components. When TanStack Query changed state semantics across major versions, the migration became painful because every component depended on the library contract.
The issue was not React Query; the issue was that the app had leaked React Query everywhere. If every component destructures vendor-specific state directly, every component becomes part of the migration surface.
With adapter hooks, the migration surface is smaller and explicit:
React Query changes
-> update generic adapters
-> update feature hooks only where behavior really changed
-> components keep using the app contract
The goal is not to make replacing React Query trivial. React Query does a lot: caching, hydration, invalidation, retries, optimistic updates. The goal is to keep the dependency contained.
React Query in clean architecture: the right boundaries
Sometimes people think clean architecture means avoiding useful libraries. It does not. Clean architecture is not about writing everything from scratch. It is about deciding where dependencies are allowed to live.
React Query is a great tool for frontend applications. But it should not define your use cases, your business language, or your component contracts. Use it where it belongs:
- Use cases define application behavior.
- Repositories handle data access.
- Feature hooks expose use cases to components.
- Generic adapters translate your app contract into React Query.
- Components consume feature-specific hooks.
That gives you the benefits of React Query without giving it control over your architecture.
What you get when React Query stays at the edge
React Query should not replace your use cases. It should adapt them. When you place React Query behind presentation adapter hooks, you keep the clean architecture rule intact: dependencies point inward, and business behavior stays independent from framework details.
Your components become simpler. Your query keys become meaningful. Your invalidation logic follows use cases. Your loading states become part of your own frontend language. And when the library changes, the whole application does not have to change with it.