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.
Keys & Caching ends with a mutation naming its own keys in onSuccess. That only refreshes your tab. A colleague with the department list open — or Rover, writing through the same service with no browser at all — leaves it stale.
Realtime invalidation fixes that without a second cache map. You register a resolver per domain action, and two delivery paths run the same resolver:
The API never sends query keys — it sends a domain action (department.create) plus any ids. Key names stay a Hub concern, so you can refactor the cache without a backend deploy. The API half is Invalidating Hub queries.
The resolver
One small module per feature, sitting next to that feature's options. A resolver takes the action's payload and returns the key prefixes it invalidates.
import { departmentQueryKeys } from './interfaces/department.dto'
import {
registerInvalidation,
registerReconnectInvalidation,
} from '@/lib/realtime-invalidation'
import { InvalidationAction } from '@/services/api/_generated/bus'
registerInvalidation(InvalidationAction.DEPARTMENT_CREATE, () => [
departmentQueryKeys.list,
])
registerInvalidation(InvalidationAction.DEPARTMENT_UPDATE, (p) => [
departmentQueryKeys.get(p.departmentId),
departmentQueryKeys.list,
])
registerInvalidation(InvalidationAction.DEPARTMENT_REMOVE, (p) => [
departmentQueryKeys.get(p.departmentId),
departmentQueryKeys.list,
])
registerReconnectInvalidation([departmentQueryKeys.root])InvalidationAction and InvalidationPayloadMap arrive on the Chowbea type bus, not Swagger — the payload for each action is typed, so p.departmentId is checked against what the API actually sends.
Keys are prefixes, so departmentQueryKeys.list sweeps every list variant beneath it (including listByCampusId). Registering the same action twice is allowed; both resolvers run and duplicate keys are invalidated once.
Wiring it up
Registration happens at module load, so the file has to be imported somewhere that always loads. That somewhere is the listener hook:
// Feature invalidation rows — must load with the listener so actions are mapped.
import '@/queries/lesson-planning/scheme-of-work.invalidation'
import '@/queries/lesson-planning/lesson-plan.invalidation'
import '@/queries/deparment/department.invalidation'
import '@/queries/reports/reports.invalidation'
import '@/queries/election/election.invalidation'Forget this import and nothing throws
An unregistered action resolves to zero keys, so the event is silently dropped and the screen just never refreshes. Add the import in the same commit as the resolver.
The actor's own screen
Do not hand-list keys in onSuccess for anything that has a resolver. Call applyInvalidation instead — same resolvers, no buffer, so your own UI updates immediately rather than waiting for the socket echo.
static readonly update = () => {
return mutationOptions({
mutationKey: departmentQueryKeys.update,
mutationFn: DepartmentQuery.update,
onSuccess: (data) => {
applyInvalidation(InvalidationAction.DEPARTMENT_UPDATE, {
departmentId: data.id,
})
},
})
}One resolver now serves both paths — miss a key and both are wrong together, which is the point. There is no way for the two to drift.
Who listens
useRealtimeInvalidation(isStaff) is mounted once in PresenceProvider, gated on the signed-in user being staff:
const isStaff = me?.user.type === 'staff'
useRealtimeInvalidation(isStaff)Students never subscribe. The events ride school-staff only — never personal (a user on several channels would get fan-out) and never campus-staff (that channel is presence).
Own echoes are deliberately not filtered out. An agent-driven write has no local onSuccess, so for those the echo is the actor's only update path.
Timing and reconnect
Three behaviours in createInvalidationBuffer you get for free but should know about:
| Behaviour | Why |
|---|---|
| Socket changes buffer for 300ms and collapse duplicates | A burst of writes becomes one round of refetches. |
Flush defers while queryClient.isMutating() > 0 | A refetch never yanks an optimistic update mid-air; it retries 300ms later. |
An event whose changes array is absent is a no-op | An API that has not been redeployed to the current wire shape degrades quietly instead of throwing. |
Reconnect is separate and deliberately coarser. school-staff keeps no history, so when Centrifugo tried recovery and failed (wasRecovering && !recovered), the buffer calls flushNow() — which invalidates every prefix from registerReconnectInvalidation and ignores the pending queue.
Live events are precise because they carry ids; a reconnect cannot know what it missed, so register the feature root and accept a few extra refetches.
Gotchas
A resolver needing an id the change lacks is skipped, not thrown. Keys containing an undefined segment are filtered out before invalidating — so a payload/resolver mismatch silently under-invalidates. Check the payload map when a screen won't refresh.
Window focus is still the backstop. React Query refetches on focus, so a lost publish self-heals when the user comes back to the tab. Never treat realtime as the only path.
Don't register keys for data students can see. The channel is staff-only; a student screen needs its own strategy.