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.

idle

Component-local state: idle · Global observer reads: idle

Keep an eye on the Halo mark in the top-left — it spins while the load runs. That mark is this site's real chrome (you're inside the SiteLayout right now), so you're watching the actual broadcast, not a staged demo.

Usage

Leaf componenttsx
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} />
}
Chrome observer (already wired in BrandSlot — for reference)tsx
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 when setLoading is 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 called setLoading(true) and not yet matched it with setLoading(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 Set of callbacks inside the hook module. Fastest path; drives the chrome's same-tab reactivity. Subscribers are managed by useGlobalLoading internally — 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 window alongside each change. Any code that wants the standard event API (devtools panels, sidecar tools, future telemetry) can window.addEventListener to 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 matching setLoading(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 local isLoading for 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):

Browser devtools consolejs
// 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.

  • /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).