Add a Data Feature
Wire a new backend resource into the app with the four-file query pattern, step by step.
Every resource in the app — campuses, users, courses — talks to the backend through the same four-file shape. Once you've wired one up, you've wired them all. This recipe walks you through adding a brand-new feature, announcement, by mirroring the campus feature that already exists under src/queries/campus.
We won't dwell on why the four files exist — that story lives on The Data Layer. Here you're following a checklist. Copy the campus folder, rename, and adjust. Each step below maps to one file.
The four files
Every feature folder holds the same set — interfaces/<name>.dto.ts (query keys), <name>.query.ts (the query class), <name>.options.ts (the options factory), and use-<name>.tsx (the hooks). Same names, same order, every time.
Step 1: Confirm the API op exists
Your query class never calls fetch directly — it calls a typed operation off the generated client, like this.op.listCampuses(). So before writing anything, check that this.op.listAnnouncements actually exists.
If the backend endpoint is brand new and the op is missing, regenerate the client:
bun run api:generateThat re-reads the OpenAPI spec and rebuilds the typed ops and contract types. If op.listAnnouncements still isn't there afterwards, the endpoint isn't in the spec yet — that's a backend conversation, not a frontend one. See The Generated Client for what regeneration produces.
Step 2: The query keys
Create the DTO file. Its only job is to define the cache keys for this entity, using the createQueryKeys factory. Pass the entity name and you get root, list, get(id), create, update, and delete keys for free — all auto-prefixed with 'announcement'.
import { createQueryKeys } from '@/lib/query-keys.factory'
const announcementQueryKeys = createQueryKeys('announcement')
export { announcementQueryKeys }Need a key the standard set doesn't cover — say, all announcements for one campus? Pass an extensions object as the second argument and the factory prefixes it for you:
const announcementQueryKeys = createQueryKeys('announcement', {
byCampus: (campusId: string) => ['campus', campusId] as const,
})
// announcementQueryKeys.byCampus('abc') -> ['announcement', 'campus', 'abc']Step 3: The query class
Create the query class. It extends BaseQuery, which hands you this.op (the generated ops) and this.exec (which unwraps the Result and throws a typed QueryError on failure — that's the Result pattern at work). Each method is one operation:
import BaseQuery from '../base.query'
class AnnouncementQuery extends BaseQuery {
list = async () => this.exec(this.op.listAnnouncements())
readonly getById = async (id: string) =>
this.exec(this.op.getAnnouncement({ id }))
// mutations follow the same shape — one method per op
readonly create = async (body: CreateAnnouncementDto) =>
this.exec(this.op.createAnnouncement(body))
}
export default AnnouncementQuery.getInstance()Always export the singleton
The last line exports AnnouncementQuery.getInstance(), not the class. getInstance returns one shared instance per subclass, so the rest of the app imports a ready-to-call object — never new AnnouncementQuery(). Forget it and every importer gets the class constructor instead.
Step 4: The options factory
Create the options factory. This is the bridge between your query methods and TanStack Query — each static returns a queryOptions or mutationOptions object. Reads pair a key from Step 2 with a method from Step 3. Mutations add onSuccess to invalidate the keys the change touched:
export class AnnouncementOptions {
static readonly list = () =>
queryOptions({
queryKey: announcementQueryKeys.list,
queryFn: AnnouncementQuery.list,
})
static readonly create = () => {
const queryClient = useQueryClient()
return mutationOptions({
mutationKey: announcementQueryKeys.create,
mutationFn: AnnouncementQuery.create,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: announcementQueryKeys.list }),
})
}
}That invalidateQueries call is what makes a new announcement appear in the list without a manual refetch. Getting the invalidation keys right is the part worth slowing down for — Keys & Caching covers exactly which keys to bust on each mutation.
Step 5: The hooks
Finally, the hooks file — the only one your components import. Each hook wraps useQuery or useMutation around an options factory and renames the fields to something readable, so a component reads loadingAnnouncements instead of a bare isLoading:
const useAnnouncements = () => {
const {
data: announcements,
isLoading: loadingAnnouncements,
...rest
} = useQuery(AnnouncementOptions.list())
return { announcements, loadingAnnouncements, ...rest }
}
export { useAnnouncements }That's the whole feature. A component now writes const { announcements, loadingAnnouncements } = useAnnouncements() and never sees a query key, an op, or a Result — exactly the point of the four files.
Mirror campus when in doubt
The campus feature under src/queries/campus is the canonical template — full CRUD, mutations, and a permission-aware useAllowedCampuses hook. When a step feels ambiguous, open the matching campus file and copy its shape.
Where to go next
A Feature End to End
The campus feature traced through all four files, with the reasoning behind each.
Keys and Caching
Which keys to invalidate on every mutation, and why.
Add a Data Table
Render your new list hook in a sortable, filterable table.
The Generated Client
What bun run api:generate produces and how the ops are typed.