A Feature End to End
The four-file pattern every data feature follows — query keys, query class, options factory, hooks — traced through one feature.
Open any folder under src/queries/ and you'll find the same four files staring back at you. That's not an accident — it's the convention. Once you've seen the pattern once, every feature in the app reads the same way, and adding a new one becomes muscle memory.
Let's trace it through campus — a small, complete CRUD feature. Each file does exactly one job, and they stack into a clean pipeline:
keys name the cache → query class talks to the API → options factory wires keys to functions → hooks hand it to your component.
By the end you'll be able to walk into src/queries/anything/ and know what each file is for before you open it.
Four files, one feature
The folder is always the same: interfaces/<feature>.dto.ts, <feature>.query.ts, <feature>.options.ts, and use-<feature>.tsx. Keep the boundaries clean and the feature stays easy to reason about — the moment data-fetching logic leaks into a component, you've broken the pattern.
Step 1: Query keys
Every cache entry needs a name — a stable array TanStack Query uses to store, find, and invalidate data. Rather than hand-write those arrays (and get them subtly wrong), each feature generates them from a factory.
import { createQueryKeys } from '@/lib/query-keys.factory'
const campusQueryKeys = createQueryKeys('campus')
export { campusQueryKeys }That one call hands you a hierarchical set of keys, all rooted at 'campus': root for the whole entity, list for the collection, get(id) for a single record, plus create, update, and delete. Because they share a prefix, invalidating root sweeps everything below it.
Need a key the base set doesn't cover? Pass extensions — and the factory auto-prefixes each one with the root so you can't forget to:
const studentQueryKeys = createQueryKeys('student', {
listByCampusId: (campusId: string) => [campusId, 'list'],
})
// → studentQueryKeys.listByCampusId('abc') === ['student', 'abc', 'list']Never type a key array by hand
Letting the factory build keys is what keeps caching reliable — every key is shaped consistently and rooted at the feature name. Keys & Caching goes deep on the hierarchy and how invalidation cascades.
Step 2: The query class
Next, the file that actually talks to the backend. The query class wraps the generated API client and exposes one method per operation the feature needs:
class CampusQuery extends BaseQuery {
create = async (payload: CreateCampusBody) =>
this.exec(this.op.createCampus(payload))
list = async () => this.exec(this.op.listCampuses())
readonly getById = async (id: string) => this.exec(this.op.getCampus({ id }))
// ...update, delete
}
export default CampusQuery.getInstance()Two inherited helpers are doing all the heavy lifting here, both from BaseQuery. this.op is the generated client — a fully typed function per backend endpoint, so this.op.listCampuses() is the typed call for GET /campuses.
this.exec unwraps the result. Every generated call returns a Result — a { data, error } object, never a thrown exception — and exec either returns the data or throws a QueryError that carries the HTTP status:
protected async exec<T>(promise: Promise<Result<T>>): Promise<T> {
const { data, error } = await promise
if (error) throw new QueryError(error)
return data
}The .getInstance() at the bottom is the last piece: BaseQuery keeps a registry so every feature exports a single shared instance, not a fresh new per import.
Why throw here when the API never does?
The API layer returns errors so nothing crashes mid-pipeline. The query layer re-throws so TanStack Query can see the failure and flip isError — and so global handlers read the status. The handoff lives in The Result Pattern.
Step 3: The options factory
Now we marry the keys to the query methods. The options factory is a class of static methods, each returning a ready-made TanStack queryOptions(...) or mutationOptions(...) object:
static readonly list = () =>
queryOptions({
queryKey: campusQueryKeys.list,
queryFn: CampusQuery.list,
})
static readonly getById = (id: string) =>
queryOptions({
enabled: !!id,
queryKey: campusQueryKeys.get(id),
queryFn: () => CampusQuery.getById(id),
})A query option is just queryKey + queryFn — the name from Step 1, the fetcher from Step 2. The enabled: !!id guard stops getById from firing with an empty id while a route param is still loading.
Mutations are where the keys earn their keep. A mutationOptions adds an onSuccess that invalidates the relevant keys, so the moment a campus is created or updated, the stale lists refetch themselves:
static readonly update = () => {
const queryClient = useQueryClient()
return mutationOptions({
mutationKey: campusQueryKeys.update,
mutationFn: CampusQuery.update,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: campusQueryKeys.list })
queryClient.invalidateQueries({ queryKey: campusQueryKeys.get(data.id) })
},
})
}Invalidate the narrowest key that's stale
Updating one campus invalidates the list and just that record's get(id). When a change ripples wider — student updates touch several list variants — the factory invalidates root instead. Keys & Caching covers choosing the right blast radius.
Step 4: The hooks
The last file is the one your components actually import. Each hook is a thin wrapper over useQuery or useMutation whose only real job is to rename fields into something readable at the call site:
const useCampuses = () => {
const {
data: campuses,
isLoading: loadingCampuses,
...rest
} = useQuery(CampusOptions.list())
return { campuses, loadingCampuses, ...rest }
}That's the whole trick. Generic data and isLoading become campuses and loadingCampuses — self-documenting names — and ...rest passes everything else (isError, refetch, and friends) straight through, so you never lose access to the full TanStack surface.
Mutation hooks do the same with mutate and isPending:
const useCreateCampus = () => {
const {
mutate: createCampus,
isPending: createCampusPending,
...rest
} = useMutation(CampusOptions.create())
return { createCampus, createCampusPending, ...rest }
}Hooks rename — they don't decide
Resist putting real logic in a hook. The one sanctioned exception is composing other hooks: useAllowedCampuses joins useCampuses with the current user's scope to filter the list. Permission and scope rules live in Permissions — keep them there, not in your data hooks.
Step 5: Consume it
After four files of plumbing, the payoff in a component is a single, readable line — no query keys, no Result, no fetch logic in sight:
const { campuses, loadingCampuses } = useCampuses()
if (loadingCampuses) return <Spinner />
return <CampusList campuses={campuses} />That's the entire point of the pattern. The component asks for campuses, gets campuses, and stays blissfully unaware of the layers underneath. When you build the next feature, copy these four files, swap the names, and you'll land in exactly the same place.
Where to go next
The Result Pattern
Why the API returns a data-or-error Result and the query class throws.
Keys & Caching
The key hierarchy and how invalidation cascades through it.
The Generated Client
Where this.op comes from and how it stays typed.
Permissions
The scope rules that hooks like useAllowedCampuses compose with.