Loading state
Want the chrome to show a load without threading props through ten layers? Call setLoading(true) and the Halo mark at the top of the page spins — in this tab and every other one. No prop drilling, no React Context: the bridge is just a global counter mirrored to localStorage.
Live demo
Hit the trigger and you broadcast a real load. The Halo mark in the top-left of this page (the design site's actual AppBar) spins for 3 seconds, and the component's own state updates right alongside it — both readouts are below.
Component-local state: idle · Global observer reads: idle
Usage
import { useEffect } from 'react'
import { useLoading } from '@halo-compliance/ui'
function VendorList() {
const { isLoading, setLoading } = useLoading()
useEffect(() => {
setLoading(true)
fetchVendors()
.then(setVendors)
.finally(() => setLoading(false))
}, [])
return isLoading ? <Skeleton /> : <List items={vendors} />
}import { useGlobalLoading } from '@halo-compliance/ui'
function MyCustomChrome() {
const loading = useGlobalLoading()
return loading ? <Spinner /> : <Idle />
}API
- useLoading()
- Returns
{ isLoading, setLoading }. Local React state for the component's own rendering, plus a side effect that mutates the global counter whensetLoadingis called. Cleanup decrements automatically on unmount so the chrome doesn't get stuck spinning if the component unmounts mid-load. - useGlobalLoading()
- Returns
boolean. True when any component anywhere has calledsetLoading(true)and not yet matched it withsetLoading(false). Also true when another tab is in a loading state (cross-tab via localStorage). Use in chrome — the mark, a progress bar, the tab favicon.
Three transports
Every state change fires through three channels in lockstep so consumers can subscribe via whichever fits.
- In-memory subscribers
- A
Setof callbacks inside the hook module. Fastest path; drives the chrome's same-tab reactivity. Subscribers are managed byuseGlobalLoadinginternally — you don't subscribe directly. - localStorage['halo:loading']
- Mirror of the counter as a string. Survives navigation, gives cross-tab visibility, and shows up in devtools for debugging.
- CustomEvent('halo:loading')
- Fires on
windowalongside each change. Any code that wants the standard event API (devtools panels, sidecar tools, future telemetry) canwindow.addEventListenerto it.
Cross-tab
Open this page in two tabs. Trigger the demo in one — the mark spins in both. The localStorage write fires a storage event in every other tab on the same origin; useGlobalLoading listens for that key. Cross-tab is read-only — each tab's hook only mutates its own count; the chrome shows "yes" if either the local count or the stored value is positive.
Reduced motion
The mark's spin is a CSS animation. Users with prefers-reduced-motion: reduce (OS-level), or who've picked Reduced in the Preferences corner (the button in the bottom-left), get no spin — the global motion override turns it off. You still get loading feedback in the component itself (skeletons, "loading…" labels); the chrome just sits still.
Anti-patterns
- Don't forget to clear. Every
setLoading(true)needs a matchingsetLoading(false)in the promise's.finally. The cleanup effect catches unmount; it doesn't catch unhandled promise rejection. - Don't gate page-level routing on this. The mark spin is an ambient indicator, not a status reporter. Use
useLoading's localisLoadingfor skeleton choreography; reserve global state for "the user should know something is happening somewhere." - Don't write to localStorage directly to fake it. The custom event fires in lockstep with localStorage; setting one without the other gets the chrome out of sync. Use
useLoading's setter (or the dev-only console pattern in the next section). - Don't spin for sub-100ms operations. A spinner that appears and disappears in a frame is visual noise. For fast paths, skip the broadcast.
Debugging from devtools
To trigger or clear the chrome spin from the console (useful when working on the mark animation itself):
// Start the spin
localStorage.setItem('halo:loading', '1')
window.dispatchEvent(new CustomEvent('halo:loading'))
// Stop the spin
localStorage.setItem('halo:loading', '0')
window.dispatchEvent(new CustomEvent('halo:loading'))This bypasses the React state path, so the chrome's local in-memory counter doesn't change — but the cross-tab listener path catches it. Useful for isolation testing the mark animation.
Related
- /app-bar#brand-slot — the mark variant consumes useGlobalLoading and spins when true.
- /preferences — the Reduced motion preference kills the spin.
- /error-handling — companion cross-cutting concern (boundaries; same chrome-observes-leaf-state pattern).