Hooks
Every shared React hook in Luminaux — signature, purpose, return shape, persistence key, and a copy-paste example.
Overview
All shared hooks live in src/hooks/. They fall into two families:
- Shared module stores — a single module-level value plus a
useSyncExternalStoresubscription, so every consumer (header toggle, customizer panel, Layout Settings page, sidebar) reads and updates the same value live.useTheme,usePresence,useFavorites, anduseRecentRoutesfollow this pattern. They also expose a plain exported setter (e.g.setPresence,toggleFavorite,pushRoute) that can be called from non-component code. - Local/effect hooks — ordinary
useState/useEffecthooks scoped to the calling component (useFullscreen,useDismiss,useNotifications,useDocumentTitle,useFixedFooterHeight,useChartTokens,useLanguage,useReveal).
The store hooks persist to localStorage under a fixed key; every such key is registered in
src/lib/appStorage.ts so "Reset to defaults" wipes it (see
utilities-and-api.md → appStorage).
Architecture & files
| Hook | File | Kind | Persistence key |
|---|---|---|---|
useTheme | src/hooks/useTheme.ts | shared store | theme |
usePresence | src/hooks/usePresence.ts | shared store | user-status |
useChartTokens | src/hooks/useChartTokens.ts | effect (MutationObserver) | — |
useFavorites | src/hooks/useFavorites.ts | shared store | nav-favorites |
useRecentRoutes | src/hooks/useRecentRoutes.ts | shared store | recent-routes |
useDocumentTitle | src/hooks/useDocumentTitle.ts | effect | — |
useLanguage | src/hooks/useLanguage.ts | wraps react-i18next | language (via detector) |
useDismiss | src/hooks/useDismiss.ts | effect (listeners) | — |
useFullscreen | src/hooks/useFullscreen.ts | effect (event) | — |
useNotifications | src/hooks/useNotifications.ts | local state (seed) | — |
useReveal | src/hooks/useReveal.ts | effect (IntersectionObserver via useInView) | — |
useFixedFooterHeight | src/hooks/useFixedFooterHeight.ts | effect (ResizeObserver) | — |
useRouteLayout | src/layout/routeLayout.ts | derived (route registry) | — |
Info
useRouteLayout is documented here for completeness but lives in src/layout/, not src/hooks/,
because it reads the route registry. See Architecture & Routing.
Usage
Import from the hook file (or via the @/ alias):
import {useTheme} from '@/hooks/useTheme'
import {usePresence} from '@/hooks/usePresence'
import {useFavorites} from '@/hooks/useFavorites'Each hook is a named export. The store hooks additionally export their imperative setter alongside the hook, for use outside React (e.g. an event handler in a plain module):
import {toggleFavorite} from '@/hooks/useFavorites'
import {pushRoute} from '@/hooks/useRecentRoutes'
import {setPresence} from '@/hooks/usePresence'API / Props
Theme & appearance
useTheme
function useTheme(): {
theme: 'light' | 'dark' // resolved appearance actually applied
mode: 'light' | 'dark' | 'system' // user's choice
setMode(next: 'light' | 'dark' | 'system'): void
setTheme(next: 'light' | 'dark'): void // back-compat alias for setMode
toggleTheme(): void // flips light <-> dark
}
export type ThemeMode = 'light' | 'dark' | 'system'- Purpose: the single source of truth for light/dark/system mode. Toggles the
.darkclass on<html>and persists the choice. - Persistence key:
localStorage('theme'). Default islight(dark never auto-applies on first load). systemmode follows the OS viamatchMedia('(prefers-color-scheme: dark)')and re-resolves live when the OS preference flips.- Shared module store, so the header toggle, the Layout Customizer, and the Layout Settings page stay in
sync. Do not re-implement this with per-component
useState.
function ThemeToggle() {
const {theme, mode, setMode} = useTheme()
return (
<button onClick={() => setMode(theme === 'dark' ? 'light' : 'dark')}>
{mode === 'system' ? 'System' : theme === 'dark' ? 'Dark' : 'Light'}
</button>
)
}usePresence
function usePresence(): {
status: PresenceStatus // 'online' | 'away' | 'busy' | 'offline'
setPresence(next: PresenceStatus): void
}
// also exported standalone:
function setPresence(next: PresenceStatus): void- Purpose: the current user's presence, shared between the header profile menu and the sidebar user card so they stay in sync.
- Return:
{status, setPresence}.PresenceStatusis re-exported from@/components/chat/StatusDot. - Persistence key:
localStorage('user-status'); falls back tocurrentUser.status(fromsrc/data/user.ts) when unset or invalid.
function PresenceSwitcher() {
const {status, setPresence} = usePresence()
return (
<select value={status} onChange={(e) => setPresence(e.target.value as typeof status)}>
<option value="online">Online</option>
<option value="away">Away</option>
<option value="busy">Busy</option>
<option value="offline">Offline</option>
</select>
)
}useChartTokens
function useChartTokens(): ChartTokens
interface ChartTokens {
primary: string
success: string
warning: string
danger: string
info: string
foreground: string
mutedForeground: string
border: string
surface: string
surfaceMuted: string
background: string
series: string[] // [primary, success, warning, info, danger]
}- Purpose: reads the app's raw semantic color tokens (the
--*CSS custom properties on<html>) so charts render theme-, skin-, and OLED-aware colors. - Reactivity: a
MutationObserverwatches<html>for changes toclass(dark mode),data-skin(skin), anddata-oled, and re-reads the tokens so charts re-skin live with no reload. seriesis a ready-made categorical palette derived from the semantic tokens — hand it straight to Recharts / ApexCharts / ECharts. During SSR / before first paint it returns a static fallback palette.
function RevenueChart({data}: { data: { name: string; value: number }[] }) {
const t = useChartTokens()
return (
<BarChart data={data}>
<Bar dataKey="value" fill={t.primary}/>
<CartesianGrid stroke={t.border}/>
</BarChart>
)
}See Charts for the full charting integration.
Navigation
useFavorites
function useFavorites(): {
favorites: string[] // pinned route paths
isFavorite(path: string): boolean
toggleFavorite(path: string): void
}
// also exported standalone:
function toggleFavorite(path: string): void- Purpose: pinned navigation items, keyed by route path. Powers the hover-star on each sidebar leaf and the synthesized "Favorites" section.
- Persistence key:
localStorage('nav-favorites')(JSON array). - Seed: first-time users get
['/layout-settings']so the Favorites section is never empty; users with a saved list (even an empty one) keep their own selection.
function FavStar({path}: { path: string }) {
const {isFavorite, toggleFavorite} = useFavorites()
return (
<button aria-pressed={isFavorite(path)} onClick={() => toggleFavorite(path)}>
{isFavorite(path) ? '★' : '☆'}
</button>
)
}useRecentRoutes
function useRecentRoutes(): {
recents: string[] // most-recent first, de-duped, capped at 6
pushRoute(path: string): void
}
// also exported standalone:
function pushRoute(path: string): void- Purpose: recently-visited route paths, feeding the "Recent" group in the ⌘K search palette.
- Behavior:
pushRoutemoves a path to the front, de-duplicates, and trims toMAX = 6. It skips no-op updates so subscribers aren't notified needlessly. - Persistence key:
localStorage('recent-routes')(JSON array). pushRouteis called on every route change inAppLayout.
function RecentList() {
const {recents} = useRecentRoutes()
return (
<ul>
{recents.map((path) => (
<li key={path}>{path}</li>
))}
</ul>
)
}useDocumentTitle
function useDocumentTitle(title?: string): void- Purpose: sets the browser-tab title to
"<title> · <brand>"(or just the brand name whentitleis empty), where the brand comes fromsrc/config/brand.ts. - Called automatically inside
PageHeader, so any page that renders aPageHeadergets a titled tab for free. Full-bleed pages (noPageHeader) must call it directly.
function BentoDashboard() {
useDocumentTitle('Dashboard') // tab reads "Dashboard · Luminaux"
// ...
}useLanguage
function useLanguage(): {
language: Language // active language descriptor { code, flag, ... }
code: string // e.g. 'en'
languages: Language[] // all selectable languages
setLanguage(code: string): void // persists via the i18next detector
}- Purpose: a thin wrapper over
react-i18nextfor the language switcher UI. - Persistence key:
localStorage('language'), written by the i18nextLanguageDetector(not the hook directly). languageresolves againstLANGUAGESinsrc/i18n/languages.ts, falling back to English.
function LanguagePicker() {
const {code, languages, setLanguage} = useLanguage()
return (
<ul>
{languages.map((l) => (
<li key={l.code} data-active={l.code === code}>
<button onClick={() => setLanguage(l.code)}>{l.nativeName}</button>
</li>
))}
</ul>
)
}Overlays & interaction
useDismiss
function useDismiss<T extends HTMLElement>(
open: boolean,
onClose: () => void,
extraRefs?: RefObject<HTMLElement | null>[],
): RefObject<T>- Purpose: shared close-on-outside-click + Escape behavior for popovers, dropdowns, and menus.
- Returns a ref to attach to the wrapping element. While
open, amousedownoutside it — or anEscapekeypress — callsonClose. extraRefslets a portaled panel (rendered outside the wrapper in the DOM) count as "inside", so clicking it doesn't dismiss. Refs are stable, so the array is read at event time and need not be a dependency.
function Menu() {
const [open, setOpen] = useState(false)
const ref = useDismiss<HTMLDivElement>(open, () => setOpen(false))
return (
<div ref={ref}>
<button onClick={() => setOpen((o) => !o)}>Menu</button>
{open && <div role="menu">…</div>}
</div>
)
}useFullscreen
function useFullscreen(): {
isFullscreen: boolean
toggle(): void
}- Purpose: the browser Fullscreen API toggle behind the header fullscreen button.
- Tracks state via the
fullscreenchangeevent, so it stays correct when the user exits via Esc or F11.togglerequests fullscreen on<html>or exits, guarding for unsupported browsers.
function FullscreenButton() {
const {isFullscreen, toggle} = useFullscreen()
return <button onClick={toggle}>{isFullscreen ? 'Exit' : 'Enter'} fullscreen</button>
}Data
useNotifications
function useNotifications(): {
notifications: AppNotification[]
unreadCount: number
markRead(id: string): void
markAllRead(): void
resolve(id: string, status: NotificationStatus): void
}- Purpose: owns the notification feed + read state for the header bell and the notifications panel.
- Seeded from
src/data/notifications.ts.unreadCountis memoized.resolveacts on arequest-type notification (approve/reject) — it records the decision and marks the row read. - This hook keeps state in local component state (not persisted); mount it once high enough that the bell and panel share the instance.
function Bell() {
const {unreadCount, notifications, markAllRead} = useNotifications()
return (
<button data-count={unreadCount} onClick={markAllRead}>
{notifications.length} notifications
</button>
)
}Scroll & motion
useReveal
function useReveal<T extends Element = HTMLDivElement>(): {
ref: RefObject<T | null> // attach to the element (or its fixed-size container)
inView: boolean // flips true ONCE when it scrolls into view
}- Purpose: a scroll-into-view gate for entrance/reveal animations. Wraps Motion's
useInViewwith the sharedREVEAL_MARGIN(fromsrc/lib/motion.ts) andonce: true, soinViewbecomestruea single time when the element scrolls within the reveal margin and never flips back. - Returns
{ref, inView}— putrefon the element (or a fixed-size container), then gate an imperativeanimate=target or defer mounting a chart-library animation oninView. - Reduced motion: the reveal is a Motion primitive, so it honors the global
reducedMotion="user"policy set byMotionProvider; gate any imperative animation you drive frominViewonuseReducedMotion()too. - Used across the widget library (
AreaTrendWidget,GaugeWidget,DonutWidget, theSparkline/MiniBarChartprimitives) and the Bento / Console dashboards to start a chart/number animation only once its card is actually seen.
import {useReveal} from '@/hooks/useReveal'
function RevealOnScroll() {
const {ref, inView} = useReveal<HTMLDivElement>()
return (
<div
ref={ref}
className="transition-all duration-500"
style={{opacity: inView ? 1 : 0, transform: inView ? 'none' : 'translateY(12px)'}}
>
Fades and rises in when scrolled into view.
</div>
)
}See Animation & effects for the REVEAL_MARGIN token and the
scroll-reveal FX components (Reveal, Parallax) built on the same idea.
Layout measurement
useFixedFooterHeight
function useFixedFooterHeight(enabled?: boolean): number // default enabled = true- Purpose: returns the live pixel height of the sticky
.app-footer, so a full-bleed page can size itself tocalc(100dvh - var(--app-header-height) - <height>px)without overlapping the pinned footer. - Re-measures on footer resize via a
ResizeObserver. Returns0whenenabledisfalse(no fixed footer to subtract). - Pass
config.fixedFooterfor routes that only pin the footer when the user enables it; leave the defaulttruefor routes that always force a fixed footer (e.g. the chat workspace).
function ChatWorkspace() {
const footerH = useFixedFooterHeight(true)
return (
<div style={{height: `calc(100dvh - var(--app-header-height) - ${footerH}px)`}}>
{/* full-height content */}
</div>
)
}useRouteLayout (lives in src/layout/)
function useRouteLayout(): {
forceHeaderOnly: boolean
forceFixedFooter: boolean
forceHideWidgets: boolean
fullBleed: boolean
}- Purpose: returns the current route's forced presentation, derived from the route registry
(
routePresentationinsrc/routes.tsx). Some full-page routes always render without the sidebar (and with tweaked footer/widgets/full-bleed padding) regardless of the user's saved layout config. - Presentation-only — it never touches the persisted customizer config, so every other route keeps
the user's own settings. To force presentation on a route, set the flags on its
appRoutesentry; no other file needs editing. - Lives in
src/layout/routeLayout.ts(notsrc/hooks/) because it reads the route registry. See Architecture & Routing.
function AppLayout() {
const {fullBleed, forceHeaderOnly} = useRouteLayout()
// drop <main> padding when fullBleed, hide the sidebar when forceHeaderOnly, …
}Configuration & customization
- Change the theme default in
useTheme.ts(loadModereturns'light'when unset) — but note the pre-paint script inindex.htmlmust agree, or you get a flash. See Design tokens & dark mode. - Change the recents cap via the
MAXconstant inuseRecentRoutes.ts. - Change the favorites seed via
DEFAULT_FAVORITESinuseFavorites.ts. - Add a persisted store hook? Register its
localStoragekey inAPP_LOCAL_KEYS(src/lib/appStorage.ts) so Reset clears it. See utilities-and-api.md → appStorage.
Examples
Composing a shared store hook with its standalone setter (setter usable outside React):
import {useRecentRoutes, pushRoute} from '@/hooks/useRecentRoutes'
import {useLocation} from 'react-router-dom'
import {useEffect} from 'react'
// In AppLayout: record every navigation.
function useTrackRoute() {
const {pathname} = useLocation()
useEffect(() => pushRoute(pathname), [pathname])
}
// Elsewhere: read the same live list.
function Palette() {
const {recents} = useRecentRoutes()
return <>{recents.join(', ')}</>
}Theme + chart tokens together (charts re-skin when the mode flips):
function Panel() {
const {theme} = useTheme()
const tokens = useChartTokens() // re-reads automatically when `.dark` toggles
return <MiniChart color={tokens.primary} theme={theme}/>
}Best practices
- Never fork a shared store into local state. Re-implementing
useTheme/usePresence/useFavoriteswithuseStatebreaks the live cross-component sync. Use the hook, or its exported setter. - Let
PageHeaderown the tab title. Only calluseDocumentTitledirectly on full-bleed pages that render noPageHeader. - Prefer
useDismissover hand-rolled outside-click listeners — it already handles Esc, portaled panels (viaextraRefs), and cleanup. - Read colors through
useChartTokens, never hardcode hex in charts — this keeps skins and dark mode in sync (see Design skins). - Register every new persisted key in
appStorage.tsso Reset stays honest.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Theme toggle in one place doesn't update another | A component re-implemented theme with local state | Use useTheme() everywhere |
| Chart colors don't change with the skin | Hardcoded hex instead of useChartTokens() | Read tokens.* / tokens.series |
| Dropdown closes when clicking its portaled menu | Portaled panel not passed to extraRefs | Pass the panel ref array to useDismiss |
| Favorites/recents lost after "Reset to defaults" | Expected — Reset wipes registered keys | These are seeded again on reload |
useFixedFooterHeight returns 0 | enabled is false, or .app-footer not mounted | Pass true / ensure the footer renders |
| Fullscreen state stuck after Esc | Not using useFullscreen (bypassing the fullscreenchange listener) | Toggle via the hook |
FAQ
Why useSyncExternalStore instead of Context? These values (theme, presence, favorites, recents)
change from many places and must stay in sync without a provider wrapping the whole tree. A module store
is lighter and update-safe.
Can I call the setters outside a component? Yes — setPresence, toggleFavorite, pushRoute, and
setMode (via useTheme) are exported for exactly that.
Where is useLayout? It's a context hook from LayoutContext, not src/hooks/. See the
Layout system.
Is useNotifications persisted? No — it's seeded local state. Mount it once so the bell and panel
share the instance.
Notes for designers & content editors
- These hooks carry state and behavior, not visible copy. User-facing text (menu labels, presence
labels, notification strings) lives in the i18n locale files under
src/locales/, not here. - The default theme is Light and dark never auto-applies on first load — this is intentional for the ThemeForest preview. Changing it is a code + pre-paint-script change, not content.
- Presence options (online/away/busy/offline) and their dot colors come from tokens and
StatusDot; adjust colors in the design tokens, not in the hook.
Related
Utilities & API client
sibling reference (api, cn, appStorage, …)
Architecture & Routing
provider tree, route registry, useRouteLayout
Getting started
install, scripts, environment
Design tokens & dark mode
the tokens useTheme / useChartTokens read
Charts
how useChartTokens feeds Recharts / ApexCharts / ECharts
Was this page helpful?
