PVR Tech Studio

Hooks

Every shared React hook in Luminaux — signature, purpose, return shape, persistence key, and a copy-paste example.

9 min read
Updated July 15, 2026

Overview

All shared hooks live in src/hooks/. They fall into two families:

  1. Shared module stores — a single module-level value plus a useSyncExternalStore subscription, so every consumer (header toggle, customizer panel, Layout Settings page, sidebar) reads and updates the same value live. useTheme, usePresence, useFavorites, and useRecentRoutes follow this pattern. They also expose a plain exported setter (e.g. setPresence, toggleFavorite, pushRoute) that can be called from non-component code.
  2. Local/effect hooks — ordinary useState/useEffect hooks 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

HookFileKindPersistence key
useThemesrc/hooks/useTheme.tsshared storetheme
usePresencesrc/hooks/usePresence.tsshared storeuser-status
useChartTokenssrc/hooks/useChartTokens.tseffect (MutationObserver)
useFavoritessrc/hooks/useFavorites.tsshared storenav-favorites
useRecentRoutessrc/hooks/useRecentRoutes.tsshared storerecent-routes
useDocumentTitlesrc/hooks/useDocumentTitle.tseffect
useLanguagesrc/hooks/useLanguage.tswraps react-i18nextlanguage (via detector)
useDismisssrc/hooks/useDismiss.tseffect (listeners)
useFullscreensrc/hooks/useFullscreen.tseffect (event)
useNotificationssrc/hooks/useNotifications.tslocal state (seed)
useRevealsrc/hooks/useReveal.tseffect (IntersectionObserver via useInView)
useFixedFooterHeightsrc/hooks/useFixedFooterHeight.tseffect (ResizeObserver)
useRouteLayoutsrc/layout/routeLayout.tsderived (route registry)

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 .dark class on <html> and persists the choice.
  • Persistence key: localStorage('theme'). Default is light (dark never auto-applies on first load).
  • system mode follows the OS via matchMedia('(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}. PresenceStatus is re-exported from @/components/chat/StatusDot.
  • Persistence key: localStorage('user-status'); falls back to currentUser.status (from src/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 MutationObserver watches <html> for changes to class (dark mode), data-skin (skin), and data-oled, and re-reads the tokens so charts re-skin live with no reload.
  • series is 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.

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: pushRoute moves a path to the front, de-duplicates, and trims to MAX = 6. It skips no-op updates so subscribers aren't notified needlessly.
  • Persistence key: localStorage('recent-routes') (JSON array).
  • pushRoute is called on every route change in AppLayout.
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 when title is empty), where the brand comes from src/config/brand.ts.
  • Called automatically inside PageHeader, so any page that renders a PageHeader gets a titled tab for free. Full-bleed pages (no PageHeader) 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-i18next for the language switcher UI.
  • Persistence key: localStorage('language'), written by the i18next LanguageDetector (not the hook directly).
  • language resolves against LANGUAGES in src/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, a mousedown outside it — or an Escape keypress — calls onClose.
  • extraRefs lets 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 fullscreenchange event, so it stays correct when the user exits via Esc or F11. toggle requests 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. unreadCount is memoized. resolve acts on a request-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 useInView with the shared REVEAL_MARGIN (from src/lib/motion.ts) and once: true, so inView becomes true a single time when the element scrolls within the reveal margin and never flips back.
  • Returns {ref, inView} — put ref on the element (or a fixed-size container), then gate an imperative animate= target or defer mounting a chart-library animation on inView.
  • Reduced motion: the reveal is a Motion primitive, so it honors the global reducedMotion="user" policy set by MotionProvider; gate any imperative animation you drive from inView on useReducedMotion() too.
  • Used across the widget library (AreaTrendWidget, GaugeWidget, DonutWidget, the Sparkline / MiniBarChart primitives) 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 to calc(100dvh - var(--app-header-height) - <height>px) without overlapping the pinned footer.
  • Re-measures on footer resize via a ResizeObserver. Returns 0 when enabled is false (no fixed footer to subtract).
  • Pass config.fixedFooter for routes that only pin the footer when the user enables it; leave the default true for 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 (routePresentation in src/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 appRoutes entry; no other file needs editing.
  • Lives in src/layout/routeLayout.ts (not src/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 (loadMode returns 'light' when unset) — but note the pre-paint script in index.html must agree, or you get a flash. See Design tokens & dark mode.
  • Change the recents cap via the MAX constant in useRecentRoutes.ts.
  • Change the favorites seed via DEFAULT_FAVORITES in useFavorites.ts.
  • Add a persisted store hook? Register its localStorage key in APP_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/useFavorites with useState breaks the live cross-component sync. Use the hook, or its exported setter.
  • Let PageHeader own the tab title. Only call useDocumentTitle directly on full-bleed pages that render no PageHeader.
  • Prefer useDismiss over hand-rolled outside-click listeners — it already handles Esc, portaled panels (via extraRefs), 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.ts so Reset stays honest.

Troubleshooting

SymptomLikely causeFix
Theme toggle in one place doesn't update anotherA component re-implemented theme with local stateUse useTheme() everywhere
Chart colors don't change with the skinHardcoded hex instead of useChartTokens()Read tokens.* / tokens.series
Dropdown closes when clicking its portaled menuPortaled panel not passed to extraRefsPass the panel ref array to useDismiss
Favorites/recents lost after "Reset to defaults"Expected — Reset wipes registered keysThese are seeded again on reload
useFixedFooterHeight returns 0enabled is false, or .app-footer not mountedPass true / ensure the footer renders
Fullscreen state stuck after EscNot 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.

Was this page helpful?