Building Year-Scoped Features
The conventions a feature follows on top of the viewing-year system — one hook, year-in-the-key queries, resetKey pagination, and three write gates.
The platform handles defaulting, carrying, and healing the year (how). Your feature touches it in exactly three places — the query key, the create payload, and the isUnlocked gate — and this page walks each one with the real patterns. If you find yourself writing more year-handling than that, you're rebuilding something the platform already does.
The hook — and which variant where
| Variant | Behavior | Use in |
|---|---|---|
useViewingAcademicYear() | Throws outside the provider | Anything inside the campus hub — pages, tables, surfaces |
useViewingAcademicYearSafe() | Returns null outside it | Persisted side panels and shared chrome that mount in every layout |
Both return the same value: { campusId, campusAcademicYearId, viewingYear, isCurrent, isUnlocked, setCampusAcademicYearId }. The throwing variant is a feature, not a hazard — a year-scoped component rendered outside the campus tree is a bug you want loud. The safe variant exists because two kinds of component genuinely live outside that tree: the status-ring chrome mounted in every layout, and persisted side panels, which are restored from a localStorage-backed registry whose container outlives the campus layout.
function RouteComponent() {
const { campusId } = Route.useParams();
const { campusAcademicYearId, isUnlocked } = useViewingAcademicYear(); // the one hook
// no year syncing, no param preservation, no ring logic — the platform does it
}Queries — year in the key, enabled gate
The standard query trio (*.query.ts, *.options.ts, use-*.tsx — see The Data Layer) grows two year-specific rules:
list: (campusId: string, campusAcademicYearId: string, params: ListFieldTripsQueryParams) =>
queryOptions({
queryKey: fieldTripQueryKeys.list(campusId, campusAcademicYearId, params), // ① year IN the key
queryFn: () => listFieldTrips({ ...params, campusAcademicYearId }),
enabled: !!campusAcademicYearId, // ② hold while resolving
}),① Year in the key is what makes switching years refetch instead of serving another year's cached rows — the stale-gradebook bug class, prevented by construction. React Query treats every distinct key as a distinct cache entry, so ['field-trip','list', campusA, year2024, …] and […, year2025, …] can never bleed into each other.
② The enabled gate covers the resolution window: on first paint campusAcademicYearId is null until the provider validates the URL. Without the gate the request fires with no year and the API answers 400 (the required-DTO rule); with it, the query simply holds until the year exists. Real examples of both rules: enrollment (listByCampusYear), payment-item, grading, cbt, lesson-planning, campus-term.
Pagination — year as resetKey
const { params, onNext, onPrev, pageIndex, canPrev } =
useCursorParams<FieldTripFilters>(20, undefined, campusAcademicYearId); // 3rd arg = resetKey
const { fieldTripsPage, loadingFieldTrips } =
useFieldTrips(campusId, campusAcademicYearId ?? '', params); // year at the call siteuseCursorParams' third argument wipes the cursor stack synchronously when it changes — switching years rewinds to page 1 in the same render, with no effect, no mirror state, and no flash of page-3-of-the-wrong-year. Never put the year inside the cursor params themselves; it's a reset trigger, not a filter.
Writes — three gates, one truth
<Can action={Action.CREATE} resource={Resource.FIELD_TRIP}>
{isUnlocked && (
<Button onClick={() => openSurface(Surface.CreateFieldTrip)}>Create Trip</Button>
)}
</Can>- Render gate — create/edit affordances are hidden (not disabled) when the year is locked:
{isUnlocked && …}inside the<Can>wrapper. That's the house style across ~23 call sites; a locked year reads as "view-only", not "buttons that refuse". - Table meta — datatables pass
readOnly: !isUnlockedthrough table meta so row actions disable together (seeenrollment/datatable.tsx). - Surfaces — the create surface stamps
campusAcademicYearIdfrom the hook into the mutation payload and early-returnsif (!isUnlocked)on submit (create-enrollment.surface.tsx,create-cbt-exam.surface.tsx). Stamping from the hook — never from a form field — guarantees the write lands in the year the user is looking at.
The UI gates are UX; the API's assertNotLocked is the truth. Bypass every gate with curl and the server still answers 400 Academic year is locked. And remember the gate is isUnlocked, never isCurrent — a past-but-unlocked year is legitimately editable, and the two axes must not be conflated.
New resource? Add Resource.FIELD_TRIP to the permission enum + role seeds on the backend; the CASL rules hydrate from /auth/me automatically.
Panels defend themselves
Persisted side panels outlive the campus layout, so a panel that renders year-scoped data guards both its context and its params:
const viewing = useViewingAcademicYearSafe();
if (!viewing) return null; // restored outside the campus tree → render nothing…and declares routeParams: ['campusId'] in its panel definition, so the registry re-validates it against the current URL on navigation instead of showing another campus's panel. Real examples: lesson-plans.panel.tsx, student-gradebook.panel.tsx, enrollment-detail.panel.tsx.