Imported from dominikdorfstetter/forja (
admin/src/hooks/AGENTS.md). Install upstream withnpx skills add dominikdorfstetter/forja --skill hooks. Copyright stays with the author.
admin/src/hooks — Reusable hooks
Custom React hooks: data-fetching wrappers around TanStack Query (e.g.
useDashboardData, useSiteContextData), media URL resolution (useMediaUrl),
navigation guards, error snackbars, and other shared UI state.
Conventions
- Extract a hook when the same data/state logic appears in more than one place — no duplication across pages.
- Query hooks key on
selectedSiteId(most data is site-scoped) and setenabledappropriately so they don't fire without a site. - Keep hooks focused and pure where possible; isolate side effects. >~150 lines is a split signal.
useRef<T>()needs an explicit initial value under React 19 + strict TS (e.g.useRef<T>(undefined)).
Render purity (react-doctor 0.7 gate)
Refs must not be written during render — react-doctor 0.7 flags it, and the
100/100 gate fails. The sanctioned idioms (used across the codebase; reference
shape: src/components/api-keys/BlockKeyDialog.tsx):
-
Reset-on-open dialogs — track the previous
openinside an effect:const prevOpenRef = useRef(false); useEffect(() => { if (open && !prevOpenRef.current) { reset(defaults); } prevOpenRef.current = open; }); -
Latest-value refs — sync in an effect, not during render:
useEffect(() => { stateRef.current = state; }); -
Server-state sync — wrap the prev-value compare +
setStatein an effect.
Copying the pre-2026-07 render-time versions of these patterns will fail the gate.
Read-only / write-permission seam (useReadOnly)
useReadOnly() is the canonical way a component reflects the current user's
write permission — prefer it over reading useAuth().canWrite directly so write
affordances express intent at the use site (#6). It returns:
readOnly/canWrite— booleans.disabledProps— spread onto an MUI control:<Button {...disabledProps} />.gate(handler)— returns the handler when writable,undefinedotherwise. MUI hides/no-opsonDelete/onChange/onClickwhen they'reundefined:<Chip onDelete={gate(() => remove(id))} />.
Defence is double-belt — UI gating and API-side RBAC (403). UI patterns:
- Hide a write button: render behind
{canWrite && …}, or give it abtn.create/btn.add/btn.delete/btn.save/btn.submittestid (Layout hides those under read-only) — ordisabled={!canWrite}. - Inline mutation triggers (
Chip onDelete,Autocomplete onChange) must be gated; theforja/require-read-only-gateESLint rule fails CI otherwise. - dnd-kit drag handles: pass
disabled: readOnlytouseDraggable/useSortableand don't spread the listeners under read-only.
The viewer walk in e2e/features/auth/read-only-mode.feature asserts a viewer
reaches no write controls across the content pages.