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:
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:
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:
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_hourThe 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:
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:
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
A Feature End to End
Watch keys, tiers, and invalidation come together in one real feature.
The Result Pattern
How queryFns return success or failure without throwing.
The Generated Client
Where the typed queryFns these options wrap come from.
Realtime Invalidation
When another tab (or Rover) mutates, map a domain action onto these same keys.
A Feature End to End
The four-file pattern every data feature follows — query keys, query class, options factory, hooks — traced through one feature.
Realtime Invalidation
Map a domain action onto query keys once, and both the actor's own screen and every other staff tab refresh from the same resolver.