Naalya Handbook
The Data Layer

Keys & Caching

How query keys are built from a factory, how cache tiers set stale time, and how mutations invalidate the right data.

Every cached query in the Education Hub needs an address — a stable identifier React Query uses to find, reuse, and throw away data. Get that address wrong and two things break at once: queries stop sharing their cache, and mutations stop refreshing the screen. So the codebase doesn't let you hand-write addresses. It generates them.

Three small pieces do all the work, and they fit together like Lego:

  • Query keys — a factory builds a consistent, hierarchical key set per entity.
  • Cache tiers — named freshness presets you spread into a query.
  • Invalidation — a mutation declares which keys it just made stale.

Learn these three and you can wire up a new feature's data layer without inventing anything.

One extra rule for year-scoped features: the viewing campusAcademicYearId goes into the query key (plus an enabled gate), so switching years refetches instead of serving stale cache — see Building Year-Scoped Features.

Keys are trees

A query key is just an array — ['campus', 'list'], ['campus', 'abc-123']. React Query treats it as a path: ['campus'] is the parent of every key that starts with 'campus'. That prefix relationship is the whole trick. Invalidate ['campus'] and you invalidate the list, every detail, everything underneath it in one move.

Rather than scatter those arrays across the codebase, you build them once with createQueryKeys. Pass an entity name and you get the standard set back:

src/queries/campus/interfaces/campus.dto.ts
import { createQueryKeys } from '@/lib/query-keys.factory'

const campusQueryKeys = createQueryKeys('campus')
// campusQueryKeys.root            → ['campus']
// campusQueryKeys.list            → ['campus', 'list']
// campusQueryKeys.get('abc-123')  → ['campus', 'abc-123']

root, list, create, update, delete, and a get(id) function — all namespaced under the entity, all derived from one string. Because every key descends from ['campus'], they nest cleanly, and nothing two entities own can ever collide.

Why a factory beats string literals

Hand-typed keys drift. One file writes ['campus', 'list'], another writes ['campuses', 'list'], and now a mutation that invalidates one silently misses the other — the screen goes stale and nobody knows why. The factory makes the entity name the single source of truth, so the keys can't disagree.

Extending a key set

Some entities need more than the standard list-and-get. The factory takes an optional second argument — an object of extra keys — and auto-prefixes each one with the entity root, so you never repeat the entity name:

src/queries/student/interfaces/student.dto.ts
const studentQueryKeys = createQueryKeys('student', {
  listByCampusId: (campusId: string) => [campusId, 'list'],
})
// studentQueryKeys.listByCampusId('c-1') → ['student', 'c-1', 'list']

You wrote [campusId, 'list']; the factory prepended 'student' for you. Extensions can be static arrays or functions of arguments — enrollmentQueryKeys adds listByClass, listByStream, listByStudent, all prefixed the same way. The result is still a tidy tree rooted at the entity.

Cache tiers

Once a key gives data an address, the next question is how long that data stays fresh before React Query refetches it. A campus list barely changes; a live analytics number changes by the minute. Rather than sprinkle magic millisecond numbers through the codebase, cache-info.tsx exports a ladder of named tiers.

Each tier is the same tiny shape — a staleTime and a gcTime:

src/queries/cache-info.tsx
const cache_5_minutes = {
  staleTime: 5 * 60 * 1000,
  gcTime: 5 * 60 * 1000,
}
// ... cache_1_minute, cache_15_minutes, cache_1_hour, cache_30_days, etc.
const default_cache = cache_1_hour

The ladder runs from cache_1_minute all the way to cache_30_days, and default_cache is an alias for cache_1_hour — the sensible middle. staleTime is how long data is considered fresh; gcTime is how long an unused query lingers in memory before garbage collection.

You pick freshness by spreading a tier into your query options — one line, no arithmetic:

src/queries/analytics/analytics.options.ts
static readonly inquiryAnalytics = (params?: InquiryTimelineParams) =>
  queryOptions({
    queryKey: analyticsQueryKeys.inquiries(params),
    queryFn: () => AnalyticsQuery.getInquiryAnalytics(params),
    ...cache_5_minutes,
  })

Volatile data gets a short tier — analytics use cache_5_minutes, health checks use cache_1_minute. Stable reference data gets a long one. When you read a query and wonder how often it refreshes, the spread line tells you at a glance.

Pick a named tier, don't inline numbers

Writing staleTime: 300000 works, but it hides the intent and invites copy-paste drift. Spread ...cache_5_minutes instead — it reads as a sentence, and if the team ever retunes "five minutes," they tune it in one place.

Invalidating on mutation

Here's where keys and caching pay off. When you create or update a campus, the cached list and detail are now wrong — they still show the old data. You could manually refetch them, but that's brittle: every caller would have to remember. Instead, the mutation declares what it dirtied, and React Query refetches automatically.

That declaration lives in the mutation's onSuccess, calling invalidateQueries with a key:

src/queries/campus/campus.options.ts
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) })
    },
  })
}

Updating a campus changes two things — its row in the list and its detail page — so the mutation invalidates both keys. Any component subscribed to either one refetches and re-renders on its own. A create only invalidates the list (there's no detail yet); a delete does the same. You describe the blast radius; the cache handles the refetch.

Invalidation is declarative, not a refetch call

invalidateQueries doesn't fetch anything itself — it marks matching keys stale. React Query refetches only the ones currently mounted, and does it for you. So the rule is: in onSuccess, name every key your mutation could have changed. Miss one and that part of the screen stays stale until the next navigation.

Because keys are a tree, you can also invalidate broadly. Passing campusQueryKeys.root would mark the list, every detail, and every extension key stale in a single call — a sledgehammer when you've changed enough that targeting individual keys isn't worth it.

Where to go next

On this page