Naalya Handbook
Recipes

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:

terminal
bun run api:generate

That 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'.

src/queries/announcement/interfaces/announcement.dto.ts
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:

src/queries/announcement/interfaces/announcement.dto.ts
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:

src/queries/announcement/announcement.query.ts
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:

src/queries/announcement/announcement.options.ts
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:

src/queries/announcement/use-announcement.tsx
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

On this page