@pyreon/hooks — API Reference
Generated from
hooks'ssrc/manifest.ts— the same source that powersllms.txtand MCPget_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see hooks.
Signal-based hooks for Pyreon — 65 reactive primitives covering state, DOM, responsive, timing, interaction, data, and composition. Every hook is SSR-safe (browser API access guarded), self-cleaning (registers onUnmount for listeners/observers/timers), and signal-native: hooks return Signal<T> / Computed<T> accessors, never plain values, so consumers compose with effect/computed without re-bridging. useControllableState is the canonical controlled/uncontrolled pattern used by every @pyreon/ui-primitives component — never reimplement the isControlled + signal + getter shape by hand.
Multiplatform
Tier: Service backend — the API is shared; the native runtimes host it
the L2 service-hook layer: every hook in PMTC's NATIVE_LOWERED_HOOKS has a web implementation and Swift/Kotlin runtime ports; the remaining hooks are web conveniences
See Multiplatform for the capability matrix and Multiplatform libraries for every package's tier.
Features
Native compiler tooling: hooksPlugin from @pyreon/hooks/native-plugin owns hook recognition and Swift/Kotlin lowering; the native CLI discovers it through pyreon.native.plugin, and direct compiler callers install it explicitly
65 signal-based hooks across 7 categories
State: useToggle, useCounter, usePrevious, useLatest, useControllableState
DOM: useEventListener, useClickOutside, useFocus, useHover, useFocusTrap, useFocusReturn, useInertOthers, useElementSize, useWindowResize, useWindowScroll, useScrollLock, useIntersection, useInfiniteScroll
Responsive: useBreakpoint, useMediaQuery, useColorScheme, useSizeClass, useReducedMotion
Timing: useDebouncedValue, useDebouncedCallback, useThrottledCallback, useInterval, useTimeout, useTimeAgo
Interaction: useClipboard, useHaptics, useShare, useLinking, useNotifications, useBiometrics, useDialog, useKeyboard, useOnline, useDocumentVisibility, useIdle
Data: useFetch — thin reactive JSON fetch ({ data, error, isPending, refetch }); the web half of the multiplatform useFetch contract
Composition: useMergedRef, useUpdateEffect, useIsomorphicLayoutEffect
Every hook is SSR-safe and auto-cleans on unmount
Signal-native return shapes — compose with
effect/computedwithout re-bridging
Complete example
A full, end-to-end usage of the package:
import {
// State
useToggle, useCounter, usePrevious, useLatest, useControllableState,
// DOM
useEventListener, useClickOutside, useFocus, useHover, useFocusTrap,
useElementSize, useWindowResize, useWindowScroll, useScrollLock, useIntersection, useInfiniteScroll,
// Responsive
useBreakpoint, useMediaQuery, useColorScheme, useSizeClass, useReducedMotion,
// Timing
useDebouncedValue, useDebouncedCallback, useThrottledCallback, useInterval, useTimeout, useTimeAgo,
// Interaction
useClipboard, useHaptics, useShare, useLinking, useNotifications, useBiometrics, useDialog, useKeyboard, useOnline, useDocumentVisibility, useIdle,
// Composition
useMergedRef, useUpdateEffect, useIsomorphicLayoutEffect,
} from '@pyreon/hooks'
// 1. useControllableState — canonical controlled / uncontrolled pattern.
// Every @pyreon/ui-primitives component uses it. Never reimplement
// the `isControlled + signal + getter` shape by hand.
function MyToggle(props: { checked?: boolean; defaultChecked?: boolean; onChange?: (v: boolean) => void }) {
const [checked, setChecked] = useControllableState({
value: () => props.checked, // controlled — a FUNCTION so the signal read tracks
defaultValue: props.defaultChecked ?? false, // uncontrolled initial — a plain value (read once)
onChange: props.onChange,
})
return <button onClick={() => setChecked(!checked())}>{checked() ? 'on' : 'off'}</button>
}
// 2. DOM listeners — auto-cleanup on unmount. Signature is (event, handler,
// options?, target?); target defaults to window (resolved once at setup).
useEventListener('resize', () => layoutSig.set(measure()))
useClickOutside(() => panelEl, () => setOpen(false))
// 3. Element observers.
const size = useElementSize(() => boxEl) // Signal<{ width, height }>
const visible = useIntersection(() => targetEl, { threshold: 0.5 }) // Signal<entry | null>
useInfiniteScroll(() => loadMore(), { threshold: 200, hasMore: () => more() })
// 4. Focus management for modals / drawers.
useFocusTrap(() => modalEl) // traps Tab inside the element while it's present (null = inert)
const scroll = useScrollLock() // scroll.lock() / scroll.unlock() — refcounted <body> lock
// 5. Responsive — driven by theme breakpoints, NOT raw media queries.
const bp = useBreakpoint() // () => string — active breakpoint name ('xs'|'sm'|'md'|'lg'|'xl')
const isMobile = useMediaQuery('(max-width: 640px)')
const colorScheme = useColorScheme() // Signal<'light' | 'dark'> from prefers-color-scheme
const sizeClass = useSizeClass() // () => 'compact' | 'regular' — SwiftUI/Compose size-class analog (min-width: 600px)
const motion = useReducedMotion() // Signal<boolean> from prefers-reduced-motion
// 6. Timing — debounced/throttled signals + callbacks.
const search = signal('')
const debounced = useDebouncedValue(search, 300) // Signal<string> — only updates after 300ms idle
const onSearch = useDebouncedCallback((q: string) => fetchResults(q), 300)
useInterval(() => poll(), 1000) // SSR-safe, auto-cleans
const sent = useTimeAgo(message.sentAt) // Signal<string> "5 minutes ago", auto-updates
// 7. Clipboard / dialog / online status — wraps the browser quirks.
const { copy, copied } = useClipboard() // `copied` auto-resets after 2s
copy('hello')
const dialog = useDialog() // native <dialog>: open signal + show/showModal/close/toggle
const online = useOnline() // Signal<boolean>
const haptics = useHaptics() // fire-and-forget: haptics.impact('light') / .notification('success') / .selection()
const share = useShare() // platform share sheet: share.text('..') / share.url('..') / share.textUrl('..', '..')
const linking = useLinking() // open external URL: linking.openUrl('https://pyreon.dev')
const notifs = useNotifications() // local notification: notifs.notify('Title', 'Body') / notifs.requestPermission()
// 8. Composition primitives.
const merged = useMergedRef(localRef, props.ref) // forward ref + capture local
useUpdateEffect(() => value(), (v) => save(v)) // watch-style (source, cb); skips first run
useIsomorphicLayoutEffect(() => measure()) // useLayoutEffect on client, no-op on SSR
// 9. More state + lifecycle.
const { count, inc, dec, reset } = useCounter(0, { min: 0, max: 10 }) // numeric counter, clamped
const { position } = useWindowScroll() // Signal<{ x, y }> scroll offset + scrollTo()
const visibility = useDocumentVisibility() // Signal<'visible' | 'hidden'> — pause work when hidden
const idle = useIdle(30_000) // Signal<boolean> — true after 30s of no activityExports
| Symbol | Kind | Summary |
|---|---|---|
useSecureStorage | hook | The imperative secret store for auth tokens / API keys / PII — the cross-platform boundary secrets must go through inste |
useControllableState | hook | Canonical controlled/uncontrolled state pattern. |
useEventListener | hook | Register a DOM event listener with automatic cleanup on unmount. |
useClickOutside | hook | Fire a callback when the user clicks outside the referenced element. |
useElementSize | hook | Reactive element size via ResizeObserver. |
useFocusTrap | hook | Trap focus inside the element returned by getEl() — Tab/Shift+Tab edge wrapping PLUS focusin containment (a programmat |
useInertOthers | hook | Apply the native inert attribute to everything OUTSIDE the element returned by getEl() — each ancestor level's sibli |
useFocusReturn | hook | The companion to useFocusTrap: captures the focused element (the trigger) when isOpen() flips true and restores focus |
useBreakpoint | hook | Returns a reactive accessor for the currently active breakpoint NAME (() => string — e.g. |
useDebouncedValue | hook | Returns a debounced signal that only updates after delayMs of source-signal idle. |
useFetch | hook | Thin reactive JSON fetch matching the multiplatform useFetch<T>(url) contract — the SAME call in a shared .tsx compi |
useClipboard | hook | navigator.clipboard.writeText wrapped with a reactive copied flag that auto-resets after options.timeout ms (defau |
useDialog | hook | Native <dialog> element wrapper. |
useTimeAgo | hook | Reactive "5 minutes ago" / "in 2 hours" relative-time string. |
useInfiniteScroll | hook | IntersectionObserver-based infinite loading. |
useMergedRef | hook | Combine multiple refs into a single callback ref — used when forwarding props.ref while also keeping a local ref to th |
useUpdateEffect | hook | Watch-style effect that skips the initial run — tracks source and fires callback(newVal, oldVal) only when source' |
useIsomorphicLayoutEffect | hook | Runs a layout-phase effect on the client (synchronous, before paint) and a no-op on the server. |
useCounter | hook | Reactive numeric counter — the numeric companion to useToggle. |
useWindowScroll | hook | Track the window scroll offset reactively via a passive scroll listener (auto-removed on unmount), plus an SSR-safe im |
useDocumentVisibility | hook | Track the Page Visibility state (document.visibilityState) reactively — "hidden" when the tab is backgrounded/minimi |
useIdle | hook | Reactive user-idle detection — true once no activity event (pointer / key / scroll / wheel by default) has fired for ` |
useToggle | hook | Boolean signal with named controls. |
useHover | hook | Track hover state. |
useFocus | hook | Track focus state. |
useMediaQuery | hook | Reactive matchMedia. |
useColorScheme | hook | Reactive OS color-scheme accessor — computed over (prefers-color-scheme: dark) (wraps useMediaQuery). |
useSizeClass | hook | Reactive size-class accessor — computed over (min-width: 600px) (wraps useMediaQuery), mapping wide → 'regular', |
useReducedMotion | hook | Reactive accessor for (prefers-reduced-motion: reduce) (a thin useMediaQuery wrapper). |
useOnline | hook | Reactive network status accessor — seeded from navigator.onLine (or true on the server), updated by online/`offlin |
useDeviceInfo | hook | Describe the device — platform branching, real screen geometry, device context for analytics. |
useSafeArea | hook | The safe-area insets of the current display — notch / Dynamic Island, home indicator, gesture bar, rounded corners. |
useSpeech | hook | Speak text aloud — speechSynthesis on the web, AVSpeechSynthesizer on iOS, TextToSpeech on Android. |
useDeviceMotion | hook | Device motion — shake gestures, tilt controls. |
useScreenOrientation | hook | Which way the display is oriented. |
useCamera | hook | Take a photo with the device camera, through the SYSTEM capture UI on every target — <input capture> on the web, UIIma |
useAudioRecorder | hook | Record from the microphone — voice notes, voice messages, dictation. |
useWakeLock | hook | Keep the screen awake — video, navigation, recipe steps. |
useBluetooth | hook | Bluetooth DISCOVERY only, on all three targets — Web Bluetooth, CoreBluetooth, and the Android adapter. |
useIntersection | hook | IntersectionObserver as a signal. |
usePrevious | hook | Track the previous value of a reactive read. |
useWindowResize | hook | Reactive window size accessor (default debounce 200ms). |
useInterval | hook | Declarative setInterval. |
useTimeout | hook | Declarative setTimeout that STARTS immediately at setup (fires once after delayms unless delay is null). |
useDebouncedCallback | hook | Returns a debounced wrapper that resets a timer on each call and invokes callback after delayms of quiet, plus `.can |
useThrottledCallback | hook | Returns a throttled wrapper (rate-limited to once per delayms; leading + trailing edge, latest-args) with a `.cancel() |
useLatest | hook | Wraps value in a mutable { current } ref object. |
useKeyboard | hook | Registers a keydown (or keyup) listener on options.target (default document) that fires handler only when `eve |
useScrollLock | hook | Lock/unlock body scroll (sets document.body.style.overflow = "hidden"). |
useHaptics | hook | Imperative haptic feedback. |
useShare | hook | Imperative Web Share API wrapper (lowers to native PyreonShare under PMTC). |
useLinking | hook | Imperative external-link opener. |
useNotifications | hook | Imperative LOCAL notifications (Web Notifications API; lowers to native PyreonNotifications under PMTC). |
useFilePicker | hook | Pick a document/file from the device — UIDocumentPickerViewController (iOS), the Storage Access Framework OpenDocument |
useImagePicker | hook | Pick an image from the device's photo library — PHPickerViewController (iOS), the Android Photo Picker (`PickVisualMedia |
fileFromUri | function | Turn the URI a picker or the camera resolved into a file a request can send. |
useBiometrics | hook | A biometric authentication gate — Face ID / Touch ID (iOS LAContext), BiometricPrompt (Android), feature-detected on t |
useGeolocation | hook | Reactive device position, shared across web / iOS / Android — the web half of the hook PMTC has always lowered natively |
useMap | hook | Map STATE — camera, markers, selection — shared across web / iOS / Android, mirroring the native PyreonMapState contai |
useWebSocket | hook | A live TEXT socket, shared across web / iOS / Android, mirroring the native PyreonWebSocket container field-for-field |
useAuth | hook | The device-proven auth-STATE container, shared across web / iOS / Android — mirrors the native PyreonAuth<User> field- |
usePush | hook | Push-notification STATE + INJECTED REGISTRATION, shared across web / iOS / Android, mirroring the native `PyreonPushNoti |
usePayments | hook | In-app-purchase STATE + INJECTED STORE ACTIONS, shared across web / iOS / Android, mirroring the native PyreonPayments |
useDatabase | hook | A tiny, SYNCHRONOUS document store, shared across web / iOS / Android, mirroring the native PyreonDatabase container ( |
useCrashReporter | hook | The web half of the cross-platform crash-reporter container (PyreonCrashReporter natively). |
useAppState | hook | Reactive app lifecycle phase — returns an ACCESSOR (call it in a reactive scope: state()), mirroring the native lifecy |
setCrashTransport | function | Register (or clear, with undefined) the function that actually uploads a crash report string — the app-wired vendor tr |
API
useSecureStorage hook
() => SecureStorage — { write(key, value): boolean; read(key): string | null; remove(key): boolean; contains(key): boolean }The imperative secret store for auth tokens / API keys / PII — the cross-platform boundary secrets must go through instead of useStorage (localStorage is plaintext + same-origin-script-readable). KEY-FIRST: write(key, value). Per-platform backing: iOS Keychain, Android AndroidKeyStore AES-GCM over app-private storage (both encrypted at rest, survive relaunch), web a MODULE-SCOPED in-memory store (the web has no OS secret store; persisting secrets to localStorage would be the exact bug this hook prevents — web secrets are process-lifetime only, fail closed). Imperative by design, NOT a reactive signal: a secret is fetched at an auth boundary, not rendered as live UI. The store is app-wide — every useSecureStorage() call sees the same secrets. On the SERVER it is INERT: reads return null, write returns false, nothing is stored. A module-scoped map is the right scope for a browser (one process, one user) and the wrong one for SSR (one process, everyone) — a secret written during a server render would otherwise be readable by every later request.
Example
const secrets = useSecureStorage()
const signIn = async () => {
const token = await api.login()
secrets.write('auth-token', token) // KEY first
}
const authed = () => fetch('/api/me', { headers: { Authorization: 'Bearer ' + (secrets.read('auth-token') ?? '') } })
const signOut = () => secrets.remove('auth-token')Common mistakes
Calling
write(value, key)— the API is KEY-FIRST (write('auth-token', token)); the old value-first order was removed because a positional native lowering would compile with the arguments crossedStoring a bearer/refresh token in
useStorageor localStorage instead — plaintext on disk, readable by any same-origin script; that is the bug this hook exists to preventExpecting web secrets to survive a reload — the web store is deliberately in-memory (no OS secret store exists); persist web sessions via httpOnly cookies / your auth provider
Wrapping
read()in a signal and rendering it as live UI — the store is imperative; fetch at the auth boundary, keep UI state in ordinary signalsExpecting
remove()to report a missing key — delete is idempotent (true even when absent), matching Keychain semanticsWriting a secret during SSR and expecting to read it back — the server arm is inert (
writereturns false) precisely so one request cannot read another's secret. Server-side secrets belong in the request context or the environment, not in a Keychain mirror
See also: useStorage (in @pyreon/storage — for NON-secret app state) · useDatabase · useFetch
useControllableState hook
<T>(opts: { value: () => T | undefined; defaultValue: T; onChange?: (v: T) => void }) => [() => T, (next: T | ((prev: T) => T)) => void]Canonical controlled/uncontrolled state pattern. Returns a [getValue, setValue] tuple where the getter reads the controlled value() when defined, else an internal signal, and the setter mutates the internal signal when uncontrolled and always fires onChange. Used by every primitive in @pyreon/ui-primitives. Never reimplement the isControlled + signal + getter shape by hand. value MUST be a FUNCTION so the controlled prop is read reactively; defaultValue is a PLAIN value (captured once as the uncontrolled initial). Controlled-vs-uncontrolled is detected once at setup from whether value() is defined.
Example
function MyToggle(props: { checked?: boolean; defaultChecked?: boolean; onChange?: (v: boolean) => void }) {
const [checked, setChecked] = useControllableState({
value: () => props.checked, // controlled — function so the signal read tracks
defaultValue: props.defaultChecked ?? false, // uncontrolled initial — plain value
onChange: props.onChange,
})
return <button onClick={() => setChecked(!checked())}>{checked() ? 'on' : 'off'}</button>
}Common mistakes
Passing
value: props.checked(not a function) — loses reactivity on prop changes; passvalue: () => props.checkedPassing
defaultValueas a getter (() => false) — it is a plain value stored once into the internal signal; a function would be stored as the value itselfMutating the returned signal directly with
.set()instead of using the returned setter — bypasses the controlled-mode / onChange handling
See also: useToggle · useCounter · usePrevious
useEventListener hook
<K extends keyof WindowEventMap>(event: K, handler: (e: WindowEventMap[K]) => void, options?: boolean | AddEventListenerOptions, target?: () => EventTarget | null) => voidRegister a DOM event listener with automatic cleanup on unmount. Signature is (event, handler, options?, target?) — event FIRST, and target is the optional last argument, a getter resolved ONCE at setup (defaults to window). Use this instead of raw addEventListener in primitives — never addEventListener / removeEventListener directly in component code (the cleanup is the hook's whole job). SSR-safe: no-ops on the server.
Example
useEventListener('resize', () => layoutSig.set(measure()))
useEventListener('keydown', (e) => {
if (e.key === 'Escape') setOpen(false)
})
// A specific element via the 4th (target) argument, resolved once at setup:
useEventListener('click', onDocClick, {}, () => document)Common mistakes
Using raw
addEventListenerinstead ofuseEventListener— you lose automaticonUnmountcleanupPassing the target FIRST (
useEventListener(window, "resize", fn)) — the signature is event-first; the target is the optional 4th argumentExpecting the
targetgetter to re-bind reactively — it is resolved ONCE at setup, so a ref that is still null then falls back towindow; attach to a stable target or read a ref that is populated by setup time
See also: useClickOutside · useKeyboard
useClickOutside hook
(ref: () => HTMLElement | null, handler: (e: MouseEvent) => void) => voidFire a callback when the user clicks outside the referenced element. Foundation for click-to-dismiss popovers, dropdowns, modals. Pair with useFocusTrap + useScrollLock for the full modal package.
Example
useClickOutside(() => panelRef(), () => setOpen(false))Common mistakes
Attaching to a ref that encompasses the entire viewport — every click anywhere except the ref itself triggers the handler; use a more specific ref (the popover panel, not the whole page)
See also: useFocusTrap · useScrollLock · useDialog
useElementSize hook
(ref: () => HTMLElement | null) => Signal<{ width: number; height: number }>Reactive element size via ResizeObserver. Returns Signal<{ width, height }> that updates whenever the observed element resizes. SSR-safe (returns { width: 0, height: 0 } until mount).
Example
const size = useElementSize(() => boxRef())
effect(() => console.log('Box is', size().width, 'x', size().height))See also: useWindowResize
useFocusTrap hook
(getEl: () => HTMLElement | null, options?: { active?: boolean | (() => boolean); initialFocus?: boolean | string | HTMLElement | (() => HTMLElement | null) } | boolean | (() => boolean)) => voidTrap focus inside the element returned by getEl() — Tab/Shift+Tab edge wrapping PLUS focusin containment (a programmatic .focus() or mouse click that lands focus outside the container is recaptured back in; Tab-only trapping misses those escapes). Required for modals / drawers / fullscreen overlays to be keyboard-accessible. The getter is read live on every event, so the trap is INERT while getEl() returns null — render the trapped element conditionally and it turns on/off with it. Concurrent traps form a scope STACK: only the most recently ACTIVATED trap whose container exists handles events, and deactivating/unmounting it reactivates the one beneath (stacked modals no longer fight over the same Tab) — arm each trap with active tied to its open state so the stack tracks OPEN order, not mount order. The optional 2nd argument arms the trap reactively WITHOUT unmounting (active: () => isOpen(), or the positional shorthand useFocusTrap(getEl, () => isOpen())) and moves focus INTO the container on activation (initialFocus: true focuses the [data-autofocus] descendant when present, else the first tabbable; or a selector / element / getter; default is no focus move, backward-compatible). The focusable query is spec-grade — it includes contenteditable, audio/video[controls], and details > summary; filters display:none / visibility:hidden / [hidden] / inert / disabled / zero-size nodes (via checkVisibility in real browsers); and orders positive-tabindex first. Restoring focus to the trigger on close is a SEPARATE concern — use useFocusReturn; making the background actually inert is useInertOthers.
Example
// Reactive arming + move focus to the first field on open.
const modalRef = signal<HTMLElement | null>(null)
useFocusTrap(() => modalRef(), { active: () => isOpen(), initialFocus: true })
useFocusReturn(() => isOpen()) // returns focus to the opener on close
// Or the single-arg form: inert while getEl() is null, no focus move.
useFocusTrap(() => modalRef())Common mistakes
Keeping the element permanently mounted (e.g.
display: none) and expecting the trap to disable when hidden — either unmount it (sogetEl()returns null) or passactive: () => isOpen()to disarm without unmounting; visibility alone does not gate itExpecting the trap to MOVE focus into the container on open — by default it only contains focus. Pass
initialFocus: true(or a selector / element / getter) to place focus on activation, and pair withuseFocusReturnto restore it on closeExpecting it to also RETURN focus to the trigger on close — that is useFocusReturn; useFocusTrap only contains focus within the container
Arming two stacked traps with the default lifetime-
active— the stack then follows MOUNT order, not open order, so the wrong trap can own focus; tie each trap to its open state (active: () => isOpen())
See also: useFocusReturn · useInertOthers · useScrollLock · useDialog · useClickOutside
useInertOthers hook
(getEl: () => HTMLElement | null, options?: { active?: boolean | (() => boolean) } | boolean | (() => boolean)) => voidApply the native inert attribute to everything OUTSIDE the element returned by getEl() — each ancestor level's sibling subtrees, from the element up to document.body. aria-modal="true" only TELLS assistive tech the background is inert; this makes it TRUE: the background becomes unfocusable, unclickable, and hidden from the accessibility tree for screen readers AND sighted keyboard users. Cleanup restores EXACTLY the prior state (an element that was already inert stays inert), and stacked overlays share a per-element refcount so an inner overlay's cleanup never un-inerts what the outer still needs. Application follows getEl() reactively — pass a signal-backed getter and it applies on mount, releases on unmount (ref → null), re-applies on identity change; active additionally arms/disarms without unmounting. script/style/template/link/meta and live regions ([aria-live], [data-live-announcer]) are skipped so screen-reader announcements keep working while the overlay is open. Siblings mounted AFTER application (a toast portaled to body) are not retroactively inert-ed. SSR-safe, self-cleaning.
Example
const dialogRef = signal<HTMLElement | null>(null)
// The dialog renders only while open, so the ref IS the lifecycle:
useInertOthers(() => dialogRef())
useFocusTrap(() => dialogRef(), { active: () => isOpen() })
useFocusReturn(() => isOpen())Common mistakes
Passing a plain
letref captured once instead of a signal-backed getter for a conditionally-rendered element — the hook watches the getter reactively; a non-reactive getter is only sampled at mount /activeflips, so the application never follows the element. Store the ref in a signal (ref={el => dialogRef.set(el)})Restoring focus to the trigger SYNCHRONOUSLY in the same flush that closes the overlay — the trigger may still be inert at that point and
.focus()silently no-ops; defer the restore a microtask (or use useFocusReturn, and let the element unmount first)Expecting it to also trap Tab —
inertmakes the background unfocusable but the trap semantics (edge wrapping, recapture) are useFocusTrap; use both for a modalManually removing
inertfrom a background element while an overlay is open — the hook's refcount still holds it and the cleanup bookkeeping will disagree; drive visibility through the hook's lifecycle instead
See also: useFocusTrap · useFocusReturn · useScrollLock · useDialog
useFocusReturn hook
(isOpen: () => boolean, options?: { returnTo?: () => HTMLElement | null }) => voidThe companion to useFocusTrap: captures the focused element (the trigger) when isOpen() flips true and restores focus to it when isOpen() flips false — so keyboard / screen-reader users return to where they were when an overlay closes, instead of the top of the page. Pass returnTo when the trigger may have unmounted by close time. SSR-safe (no-op on the server), self-cleaning (the watcher is removed on unmount).
Example
const open = signal(false)
useFocusReturn(() => open()) // focus returns to the opener on close
useFocusTrap(() => dialogRef()) // focus is trapped while the dialog is presentCommon mistakes
Passing the open state as a plain boolean instead of a getter —
useFocusReturn(open())reads it once and never tracks the transition; pass() => open().Expecting it to move focus INTO the overlay on open — that is useFocusTrap / autofocus. useFocusReturn only handles the RETURN on close.
See also: useFocusTrap · useScrollLock · useDialog
useBreakpoint hook
(breakpoints?: Record<string, number>) => () => stringReturns a reactive accessor for the currently active breakpoint NAME (() => string — e.g. "xs" / "sm" / "md" / "lg" / "xl"), driven by the theme breakpoints, not raw media queries — reads theme.breakpoints so swapping themes (or unit systems) Just Works. Compare the read against a name; use useMediaQuery for one-off arbitrary queries.
Example
const bp = useBreakpoint()
{() => bp() === 'lg' || bp() === 'xl' ? <DesktopNav /> : <MobileNav />}Common mistakes
Using
useBreakpointfor a one-off media query like(prefers-contrast: more)—useBreakpointreads theme breakpoints only; useuseMediaQueryfor arbitrary media queries
See also: useMediaQuery
useDebouncedValue hook
<T>(source: Signal<T> | (() => T), delayMs: number) => Signal<T>Returns a debounced signal that only updates after delayMs of source-signal idle. Use for search-as-you-type, filter inputs, anywhere downstream effects shouldn't fire on every keystroke. The PAIR — useDebouncedCallback — debounces a function call instead of a value.
Example
const search = signal('')
const debouncedSearch = useDebouncedValue(search, 300)
effect(() => fetchResults(debouncedSearch()))Common mistakes
Reading the debounced signal immediately after setting the source — it still holds the OLD value during the debounce window; effects downstream of the debounced signal are correct, but imperative reads in the same tick are stale
See also: useDebouncedCallback · useThrottledCallback
useFetch hook
<T>(url: string, init?: { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; headers?: Record<string, string>; body?: string }) => { data: Signal<T | undefined>; error: Signal<unknown>; isPending: Signal<boolean>; refetch: () => void }Thin reactive JSON fetch matching the multiplatform useFetch<T>(url) contract — the SAME call in a shared .tsx compiles to native PyreonFetch<T> containers on iOS (URLSession .task {}) and Android (LaunchedEffect + kotlinx-serialization) via PMTC, while this runs on web. Fires once at component setup (client only — SSR renders the not-yet-loaded state); each refetch() aborts the previous in-flight request so a slow stale response can never clobber a fresh one; unmount aborts too. Deliberately thinner than @pyreon/query: no cache, no dedup, no retries.
Example
type Quote = { id: number; text: string }
const quotes = useFetch<Quote[]>('/api/quotes.json')
<Show when={quotes.isPending}><Text>Loading…</Text></Show>
<For each={() => quotes.data() ?? []} by={(q) => q.id}>{(q) => <Text>{q.text}</Text>}</For>Common mistakes
Reading
quotes.datawithout calling it in non-JSX code — the fields are Signals;quotes.data()reads the value. In JSX child position the bare signal works (accessor children render reactively)Expecting data during SSR — the fetch only runs client-side; server HTML renders the
undefined-data state and the request fires after hydrationUsing a reactive/computed URL — v1 takes a plain string captured once (PMTC requires a string literal for native emit anyway); call
refetch()for manual re-runs, or use@pyreon/queryfor signal-driven keysReaching for useFetch when you need caching, request dedup, retries, or mutations — that is
@pyreon/query(TanStack) territory; useFetch is the thin multiplatform primitiveForgetting the non-2xx contract — HTTP errors land in
error()as[Pyreon] useFetch <url>: HTTP <status>, they do NOT throwSending a token in
init.headersand expecting it to update after sign-in —initis read ONCE, when the request is built at setup, on every target. A header value may be a runtime string, but one that changes belongs in@pyreon/query: itsqueryFnbuilds the request on every run, and aqueryKeycarrying the user re-runs it after sign-inA header value that may be
null(authorization: auth.accessToken()) — the web sends the textnull, and native refuses it with a warning; write`Bearer ${auth.accessToken() ?? ''}`
See also: useOnline
useClipboard hook
(options?: { timeout?: number }) => { copy: (text: string) => Promise<boolean>; copied: () => boolean; text: () => string }navigator.clipboard.writeText wrapped with a reactive copied flag that auto-resets after options.timeout ms (default 2000). copy resolves true on success / false on failure (never throws). text() is the last successfully-copied string. Use the copied signal to flash a "Copied!" UI cue without manual timer management.
Example
const { copy, copied } = useClipboard()
<button onClick={() => copy(token)}>{copied() ? 'Copied!' : 'Copy'}</button>Common mistakes
Passing a bare number (
useClipboard(3000)) — the argument is an options object:useClipboard({ timeout: 3000 })
See also: useDialog · useOnline
useDialog hook
(options?: { onClose?: () => void }) => { open: () => boolean; show: () => void; showModal: () => void; close: () => void; toggle: () => void; ref: (el: HTMLDialogElement | null) => void }Native <dialog> element wrapper. open is the reactive OPEN-STATE signal (call it to read: dialog.open()); show() opens non-modal, showModal() opens with backdrop + focus, close() closes, toggle() flips. Wires the native close event so open stays in sync (and fires options.onClose) when the user presses Escape.
Example
const dialog = useDialog()
<button onClick={dialog.showModal}>Open</button>
<dialog ref={dialog.ref}><button onClick={dialog.close}>Close</button></dialog>Common mistakes
Using
dialog.openas an OPENER — it is the open-STATE signal, not a method; open withdialog.show()/dialog.showModal()Rendering the
<dialog>behind a conditional<Show>— it must be in the initial render so the ref callback binds before you callshowModal()
See also: useFocusTrap · useScrollLock
useTimeAgo hook
(date: Date | (() => Date), opts?: UseTimeAgoOptions) => Signal<string>Reactive "5 minutes ago" / "in 2 hours" relative-time string. Auto-updates on a sensible interval (every minute under an hour, every hour under a day, etc.) so the UI stays accurate without manual scheduling. Cleans up the interval on unmount.
Example
const sent = useTimeAgo(message.sentAt)
<span>{sent}</span>See also: useInterval · useDebouncedValue
useInfiniteScroll hook
(onLoadMore: () => void | Promise<void>, opts?: { threshold?: number; loading?: () => boolean; hasMore?: () => boolean; direction?: "up" | "down" }) => { ref: (el: HTMLElement | null) => void; triggered: () => boolean }IntersectionObserver-based infinite loading. Attach the returned ref to the SCROLL CONTAINER — the hook injects an invisible sentinel at the boundary; when it scrolls into view, onLoadMore fires. triggered() reflects whether the sentinel is currently visible. loading (skip while a load is in flight) and hasMore (stop once the last page is reached) are accessor guards; threshold is the px distance from the edge (default 100), direction picks the top/bottom boundary (default down).
Example
const { ref, triggered } = useInfiniteScroll(loadNextPage, { threshold: 200, loading: () => loading(), hasMore: () => hasMore() })
<div ref={ref} style={{ overflowY: 'auto', height: '400px' }}>
<For each={items()} by={(i) => i.id}>{(item) => <Row data={item} />}</For>
</div>Common mistakes
Attaching
refto the sentinel instead of the scroll CONTAINER — the hook creates its own sentinel;refgoes on the scrollable elementA container with
overflow: hiddenand no scroll — the injected sentinel is always clipped, so IntersectionObserver never firesForgetting
hasMore: () => hasMore()— the hook keeps callingonLoadMoreeven after the last page
See also: useIntersection · useWindowScroll
useMergedRef hook
<T>(...refs: (Ref<T> | RefCallback<T> | null | undefined)[]) => RefCallback<T>Combine multiple refs into a single callback ref — used when forwarding props.ref while also keeping a local ref to the same element. Each provided ref (callback or object) receives the element on mount and null on unmount.
Example
const localRef = ref<HTMLDivElement>()
const merged = useMergedRef(localRef, props.ref)
<div ref={merged}>...</div>See also: useEventListener
useUpdateEffect hook
<T>(source: () => T, callback: (newVal: T, oldVal: T | undefined) => void | (() => void)) => voidWatch-style effect that skips the initial run — tracks source and fires callback(newVal, oldVal) only when source's value changes after mount (oldVal is undefined on the first change). Use for "save on change but not on first render" patterns where the initial value is already persisted. Note the argument order is (source, callback) — NOT React's (effect, deps).
Example
useUpdateEffect(() => value(), (val) => api.save(val))
// Doesn't fire on initial mount — only on subsequent value changesSee also: useIsomorphicLayoutEffect
useIsomorphicLayoutEffect hook
(fn: () => void | (() => void)) => voidRuns a layout-phase effect on the client (synchronous, before paint) and a no-op on the server. Use when you need to read DOM measurements before the next paint without triggering an SSR mismatch warning.
Example
const ref = signal<HTMLDivElement | null>(null)
useIsomorphicLayoutEffect(() => {
const el = ref()
if (el) widthSig.set(el.getBoundingClientRect().width)
})See also: useUpdateEffect · useElementSize
useCounter hook
(initial?: number, opts?: { min?: number; max?: number }) => { count: Signal<number>; inc: (d?: number) => void; dec: (d?: number) => void; set: (v: number) => void; reset: () => void }Reactive numeric counter — the numeric companion to useToggle. inc / dec step by d (default 1), set assigns absolutely, reset returns to the initial value; every write is clamped into [min, max] when bounds are given (the initial value is clamped too). count is the reactive value signal.
Example
const { count, inc, dec, reset } = useCounter(0, { min: 0, max: 10 })
<button onClick={() => dec()}>-</button><span>{count}</span><button onClick={() => inc()}>+</button>Common mistakes
Calling the exposed
countsignal's.set()directly to bypass clamping — useset()/inc()/dec()somin/maxare enforced
See also: useToggle · useControllableState
useWindowScroll hook
() => { position: () => { x: number; y: number }; scrollTo: (o: { x?: number; y?: number; behavior?: ScrollBehavior }) => void }Track the window scroll offset reactively via a passive scroll listener (auto-removed on unmount), plus an SSR-safe imperative scrollTo (omitted axes keep their current value). Use for scroll-to-top buttons, scroll-progress bars, sticky-header reveal, parallax. SSR-safe: position() is { x: 0, y: 0 } on the server.
Example
const { position, scrollTo } = useWindowScroll()
<Show when={() => position().y > 400}>
<button onClick={() => scrollTo({ y: 0, behavior: 'smooth' })}>Top</button>
</Show>See also: useElementSize · useInfiniteScroll · useIntersection
useDocumentVisibility hook
() => () => "visible" | "hidden"Track the Page Visibility state (document.visibilityState) reactively — "hidden" when the tab is backgrounded/minimized, "visible" otherwise. Use it to pause work the user can't see (polling, video, animations, expensive timers) and resume on return. SSR-safe (returns "visible" on the server); the visibilitychange listener is removed on unmount.
Example
const visibility = useDocumentVisibility()
effect(() => { visibility() === 'hidden' ? pausePolling() : resumePolling() })See also: useOnline · useIdle
useIdle hook
(timeoutMs?: number, opts?: { events?: readonly string[]; initialState?: boolean }) => () => booleanReactive user-idle detection — true once no activity event (pointer / key / scroll / wheel by default) has fired for timeoutMs (default 60000), back to false on the next interaction. Every listener and the timer are removed on unmount. Use for auto-logout, "are you still there?" prompts, presence away-status, pausing background work. SSR-safe (listeners register in onMount).
Example
const idle = useIdle(30_000)
effect(() => { if (idle()) showAwayBanner() })Common mistakes
Expecting it to fire once —
idleis a live boolean signal that flips false again on the next activity event; read it reactively
See also: useDocumentVisibility · useInterval · useOnline
useToggle hook
useToggle(initial?: boolean) => { value: () => boolean; toggle: () => void; setTrue: () => void; setFalse: () => void }Boolean signal with named controls. Returns an OBJECT (not a tuple): value is a signal accessor, plus toggle / setTrue / setFalse mutators. For a numeric counter use useCounter.
Example
const menu = useToggle()
<button onClick={menu.toggle}>Menu</button>
<Show when={() => menu.value()}>…</Show>Common mistakes
Destructuring it as a tuple (
const [open, toggle] = useToggle()) — it returns an OBJECT{ value, toggle, setTrue, setFalse }; destructure by name.Reading
valuewithout calling it —valueis a signal accessor; readvalue()inside a reactive scope.
See also: useCounter · useDialog
useHover hook
useHover() => { hovered: () => boolean; props: { onMouseEnter: () => void; onMouseLeave: () => void } }Track hover state. Returns a hovered signal accessor plus props you SPREAD onto the target element — the hook does not auto-attach any listener.
Example
const h = useHover()
<div {...h.props}>{() => h.hovered() ? 'Hovering' : 'Idle'}</div>Common mistakes
Forgetting to spread
propsonto the element — nothing updates without it (the hook attaches no listeners itself).Expecting it to fire on touch — it uses
onMouseEnter/onMouseLeaveonly, so it does not react on touch devices.
See also: useFocus · useEventListener
useFocus hook
useFocus() => { focused: () => boolean; props: { onFocus: () => void; onBlur: () => void } }Track focus state. Returns a focused signal accessor plus props (onFocus/onBlur) to SPREAD onto the element — no auto-attach.
Example
const f = useFocus()
<input {...f.props} />Common mistakes
Forgetting to spread
props— the hook registers no listeners itself; without the spreadfocusednever changes.
See also: useHover · useFocusTrap
useMediaQuery hook
useMediaQuery(query: string) => () => booleanReactive matchMedia. Returns a matches signal accessor; subscribes to the media query on mount and updates on change (listener auto-removed on unmount).
Example
const isWide = useMediaQuery('(min-width: 768px)')
<Show when={() => isWide()}><Sidebar /></Show>Common mistakes
Expecting a correct value on the FIRST render / during SSR — the signal is seeded
falseand only corrected inonMount, so the first render always readsfalseeven if the query would match. Gate visual differences to avoid a flash.Reading it without calling the accessor —
useMediaQuery(q)returns() => boolean; call it in a reactive scope.
See also: useColorScheme · useReducedMotion · useBreakpoint
useColorScheme hook
useColorScheme() => () => 'light' | 'dark'Reactive OS color-scheme accessor — computed over (prefers-color-scheme: dark) (wraps useMediaQuery). Returns 'dark' / 'light'.
Example
const scheme = useColorScheme()
<body data-theme={() => scheme()} />Common mistakes
Reads
'light'on the first render / SSR regardless of OS preference — it inheritsuseMediaQuery's seed-then-correct-on-mount behavior. Use a pre-paint script for a flash-free initial theme.Returns an accessor — call
scheme()to read.Using it to theme a component — it reads the OS ONLY, so it ignores the mode an app chose (
<PyreonUI mode>, a zero theme toggle) and the page's declaredcolor-scheme. Theme withuseColorMode()from@pyreon/core, the framework-wide mode; keep this for code that genuinely wants the OS setting.
See also: useMediaQuery · useReducedMotion
useSizeClass hook
useSizeClass() => () => 'compact' | 'regular'Reactive size-class accessor — computed over (min-width: 600px) (wraps useMediaQuery), mapping wide → 'regular', narrow → 'compact' (the SwiftUI/Android size-class analog for shared multi-platform code).
Example
const size = useSizeClass()
<Show when={() => size() === 'regular'}><TwoColumn /></Show>Common mistakes
First render / SSR is always
'compact'(inheritsuseMediaQuery's pre-mountfalse).Returns an accessor — call
size().
See also: useMediaQuery · useBreakpoint
useReducedMotion hook
useReducedMotion() => () => booleanReactive accessor for (prefers-reduced-motion: reduce) (a thin useMediaQuery wrapper). Gate animations on it for accessibility.
Example
const reduced = useReducedMotion()
<Transition enter={() => reduced() ? '' : 'fade-in'}>…</Transition>Common mistakes
First render / SSR reports
false("motion allowed") untilonMount— inheritsuseMediaQueryseeding.Returns an accessor — call it.
See also: useMediaQuery · useColorScheme
useOnline hook
useOnline() => () => booleanReactive network status accessor — seeded from navigator.onLine (or true on the server), updated by online/offline window events. SSR-safe (guards on isClient); listeners auto-removed via onCleanup.
Example
const online = useOnline()
<Show when={() => !online()}><OfflineBanner /></Show>Common mistakes
Treating it as reliable connectivity —
navigator.onLineonly reflects the OS network interface, not real reachability; a captive portal or dead server still readstrue.Returns an accessor — call
online().
See also: useDocumentVisibility · useIdle
useDeviceInfo hook
useDeviceInfo() => { platform: () => 'web' | 'ios' | 'android'; model: () => string; osVersion: () => string; isTouch: () => boolean; screen: () => { width: number; height: number; scale: number } }Describe the device — platform branching, real screen geometry, device context for analytics. platform needs no runtime on native: it is a COMPILE-TIME constant per target. model and osVersion are real on iOS/Android and deliberately EMPTY STRINGS on the web, because the browser cannot answer them reliably (navigator.platform is deprecated, UA Client Hints are Chromium-only, and UA parsing rots as browsers change their strings) — and these are the fields that end up in analytics and support tickets, where a plausible wrong answer costs more than a missing one. screen reads through on every access rather than caching, so a fold, rotation or Stage Manager resize is reflected instead of silently reporting the old geometry.
Example
const device = useDeviceInfo()
<Show when={() => device.platform() !== 'web'}>
<Text>{device.model()} · {device.osVersion()}</Text>
</Show>Common mistakes
Expecting
model/osVersionon the web — they are empty by design; branch onplatform()first, or treat empty as unknown.Parsing the User-Agent yourself to fill the gap — that is the failure mode this hook refuses to ship; it produces answers that look right and rot silently.
Caching
screen()in a const at setup — it is a live read; a fold or rotation changes it.Every member is an accessor — call them.
See also: useSizeClass · useBreakpoint · useWakeLock
useSafeArea hook
useSafeArea() => () => { top: number; right: number; bottom: number; left: number }The safe-area insets of the current display — notch / Dynamic Island, home indicator, gesture bar, rounded corners. The one device fact a multiplatform app cannot work around at the app level: without it content draws under the notch, or every screen pads by a hard-coded guess that is wrong on the next device. Returns ONE accessor rather than four because the values move together on rotation and separate accessors invite a torn read. Sources: env(safe-area-inset-*) read off an inert probe element on the web (CSS environment variables are not exposed to script any other way — needs viewport-fit=cover, and reports zeros without it, which is correct rather than broken), safeAreaInsets on iOS, WindowInsets on Android.
Example
const safe = useSafeArea()
<Stack style={() => ({ paddingTop: `${safe().top}px` })}>…</Stack>Common mistakes
Destructuring at setup (
const { top } = safe()) — that captures one frame; rotation changes it. Read inside the reactive scope.Expecting non-zero values on the web without
viewport-fit=coverin the viewport meta — zeros mean nothing is obscured.Hard-coding notch padding instead — it is wrong on the next device, which is what this hook exists to stop.
See also: useScreenOrientation · useDeviceInfo
useSpeech hook
useSpeech() => { supported: () => boolean; speaking: () => boolean; speak: (text: string) => Promise<boolean>; stop: () => void }Speak text aloud — speechSynthesis on the web, AVSpeechSynthesizer on iOS, TextToSpeech on Android. CANCELS before each speak(): queueing is the platform default on all three, so without it a second press talks OVER the first instead of replacing it, and the assertion is identical across the three arms. Rate, pitch and voice selection are deliberately out of scope — the platforms disagree on their ranges and on how voices are identified, so one name would mean three different things, or a lowest-common-denominator useless on all three. Disposal cancels: speech outlives the DOM on every browser, so an in-flight utterance would talk over the next screen.
Example
const speech = useSpeech()
<Button onClick={() => speech.speak(article())}>Read aloud</Button>Common mistakes
Expecting queued playback — each speak() replaces the last; queue it yourself if you want a playlist.
Looking for rate/pitch/voice — deliberately absent; they do not cross.
Empty text is a no-op returning false, not an empty utterance.
See also: useAudioRecorder · a11y
useDeviceMotion hook
useDeviceMotion() => { supported: () => boolean; active: () => boolean; start: () => Promise<boolean>; stop: () => void; acceleration: () => { x: number; y: number; z: number }; rotation: () => { x: number; y: number; z: number } }Device motion — shake gestures, tilt controls. Has an explicit start() rather than listening on mount, because an always-on hook would be wrong on ALL THREE targets: iOS Safari gates DeviceMotionEvent behind a permission prompt that only works from a user gesture, and both native targets want an explicit start/stop so the sensor is not draining battery for a screen nobody is looking at. start() resolves false on denial rather than throwing. On engines WITHOUT requestPermission (everything but iOS Safari) its absence is a GRANT, not a failure.
Example
const motion = useDeviceMotion()
<Button onClick={() => motion.start()}>Enable tilt</Button>
<Show when={() => motion.active()}><Tilt v={motion.rotation()} /></Show>Common mistakes
Calling start() outside a user gesture on iOS Safari — the prompt throws; it is caught and resolves false.
Expecting updates without start() — deliberately opt-in, to keep the sensor off.
Forgetting stop() when the view is only hidden rather than unmounted.
See also: useScreenOrientation · useDeviceInfo
useScreenOrientation hook
useScreenOrientation() => { type: () => 'portrait' | 'landscape'; angle: () => number }Which way the display is oriented. READ-ONLY by design: locking does not cross — screen.orientation.lock() is Chromium-only and fullscreen-gated on the web, and on iOS orientation is an app-level declaration (supportedInterfaceOrientations) rather than something a view can request. A lock() that silently no-ops on two of three targets is worse than a surface that states what it covers. type is normalised to the part that is true everywhere; the primary/secondary distinction the web exposes lives in angle (0 / 90 / 180 / 270), so nothing is lost.
Example
const o = useScreenOrientation()
<Show when={() => o.type() === 'landscape'}><WideLayout /></Show>Common mistakes
Looking for
lock()— it is deliberately absent; declare supported orientations at the app level instead.Treating
angleas the orientation — a device can report 90 in either landscape direction; branch ontype().Both members are accessors — call them.
See also: useSafeArea · useSizeClass · useDeviceInfo
useCamera hook
useCamera() => { capture: () => Promise<string | null>; isAvailable: () => boolean }Take a photo with the device camera, through the SYSTEM capture UI on every target — <input capture> on the web, UIImagePickerController on iOS, an image-capture intent on Android. Mirrors useImagePicker exactly, since the two differ only in which system flow they open: capture() resolves a URI or null, and NEVER rejects — a cancel and an unavailable camera are the same outcome to a caller (no photo). Because the system UI owns the permission prompt, there is no permission plumbing to get subtly different per platform. A CUSTOM in-app viewfinder is deliberately out of scope: an AVCaptureSession layer, a CameraX PreviewView and a <video> element are not one thing wearing three hats — reach for useNativeModule there, the same escape hatch useBluetooth names for GATT.
Example
const cam = useCamera()
const shoot = async () => {
const uri = await cam.capture()
if (uri !== null) photo.set(uri)
}Common mistakes
Expecting a live viewfinder — this opens the system capture UI; a bespoke preview is
useNativeModuleterritory.Wrapping capture() in try/catch expecting a throw on cancel — it resolves null.
Assuming the web arm opens a camera on desktop —
captureis a mobile hint; desktop shows a file dialog.
See also: useImagePicker · useFilePicker · usePermissions
useAudioRecorder hook
useAudioRecorder() => { supported: () => boolean; recording: () => boolean; start: () => Promise<boolean>; stop: () => Promise<string | null>; error: () => string }Record from the microphone — voice notes, voice messages, dictation. start() RESOLVES false on a denied permission rather than throwing: that is the single most likely outcome of the call and an ordinary branch in any UI that uses it, so callers get an if, not a try (the same contract useWakeLock.request() uses). stop() resolves a URL — an object URL on the web, a file URL on iOS/Android — because that is the one representation all three targets produce and every consumer can use; handing back a platform-shaped buffer would push the difference onto the caller. A zero-length capture resolves null rather than an empty URL. Disposal releases the microphone tracks, which is what turns the OS recording indicator off.
Example
const rec = useAudioRecorder()
const done = async () => {
const url = await rec.stop()
if (url !== null) clip.set(url)
}Common mistakes
Wrapping start() in try/catch expecting a throw on denial — it resolves false and sets error().
Assuming stop() always yields a URL — a zero-length capture is null.
Forgetting to revoke the object URL on the web when the clip is discarded.
Every member is an accessor — call them.
See also: Audio · usePermissions
useWakeLock hook
useWakeLock() => { active: () => boolean; supported: () => boolean; request: () => Promise<boolean>; release: () => Promise<void> }Keep the screen awake — video, navigation, recipe steps. Lowers to isIdleTimerDisabled on iOS and FLAG_KEEP_SCREEN_ON on Android. The WEB arm carries a normalization the native ones do not need: a WakeLockSentinel is released by the browser whenever the document hides and is NOT reacquired, while the native flag survives backgrounding — so the hook listens for the sentinel's release event and re-acquires on visibilitychange unless the caller explicitly released. Without both halves the same call leaves the screen sleeping on web and lit on native.
Example
const wake = useWakeLock()
onMount(() => { void wake.request() })
<Show when={() => wake.active()}><Badge>Screen stays on</Badge></Show>Common mistakes
Assuming a lock survives a tab switch by itself — on the web it does not; this hook re-acquires for you, but a hand-rolled
navigator.wakeLock.requestwill silently stop holding.Treating a rejected request as an error — low battery and background tabs both refuse, so
request()resolvesfalserather than throwing.activeandsupportedare accessors — call them.Holding a lock for the page rather than the view that needs it — the lock releases on scope disposal, so acquire it in the component that owns the awake-screen requirement.
See also: useAppState · useDocumentVisibility
useBluetooth hook
useBluetooth() => { available: () => boolean; scanning: () => boolean; devices: () => BluetoothDevice[]; error: () => string; scan: () => void; stopScan: () => void }Bluetooth DISCOVERY only, on all three targets — Web Bluetooth, CoreBluetooth, and the Android adapter. GATT (connect / read / write a characteristic) is deliberately out of scope: it is where the three platforms stop resembling each other, and a surface that only half-crosses is worse than one that states what it covers. devices is FIRST-SEEN order deduped by id, asserted on every target rather than inherited from whatever the platform hands back.
Example
const bt = useBluetooth()
<Show when={() => bt.available()}>
<Button onClick={() => bt.scan()}>Scan</Button>
<For each={bt.devices()} by={(d) => d.id}>{(d) => <Text>{d.name}</Text>}</For>
</Show>Common mistakes
Expecting a connect/read/write surface — this is discovery only; use
useNativeModulefor GATT, which is genuinely platform-shaped.Calling
scan()without checkingavailable()— it setserrorand returns rather than throwing, so a missing check reads as a silently empty list.Assuming scanning stops on its own — BLE peripherals advertise continuously; call
stopScan()or let scope disposal do it, or the radio stays on.
See also: useNativeModule · usePermissions
useIntersection hook
useIntersection(getEl: () => HTMLElement | null, options?: IntersectionObserverInit) => () => IntersectionObserverEntry | nullIntersectionObserver as a signal. On mount reads getEl() once and (if non-null) observes it, writing the latest entry into a signal it returns. Auto-disconnects on unmount. Returns null until the observer first fires.
Example
let el!: HTMLElement
const entry = useIntersection(() => el)
<div ref={(e) => (el = e)}>{() => entry()?.isIntersecting ? 'visible' : 'hidden'}</div>Common mistakes
getEl()is read ONCE inonMount— the element is not tracked reactively, so an element that mounts LATER or changes identity will not be observed. Ensure the ref is set before mount.Reading the accessor before the first observation — it is
nulluntil the observer fires; guard withentry()?..
See also: useElementSize · useInfiniteScroll
usePrevious hook
usePrevious<T>(getter: () => T) => () => T | undefinedTrack the previous value of a reactive read. Runs an effect over getter(); returns an accessor for the value from BEFORE the last change (undefined until the getter changes once).
Example
const count = signal(0)
const prev = usePrevious(() => count())
// after count.set(5): prev() === 0Common mistakes
Passing a plain value instead of a getter —
usePrevious(count())snapshots once; pass() => count()so the effect tracks the signal.Reading
prev()on first render — it isundefineduntil the tracked value changes at least once.
See also: useLatest · useUpdateEffect
useWindowResize hook
useWindowResize(debounceMs?: number) => () => { width: number; height: number }Reactive window size accessor (default debounce 200ms). Seeded from window.innerWidth/Height (or {0,0} on the server); a debounced resize listener updates it (listener + pending timer cleaned up on unmount).
Example
const size = useWindowResize()
<div>{() => size().width + '×' + size().height}</div>Common mistakes
Expecting real dimensions on the first render / SSR — it seeds
{ width: 0, height: 0 }on the server and until mount.Returns an accessor — call
size().
See also: useElementSize · useWindowScroll · useBreakpoint
useInterval hook
useInterval(callback: () => void, delay: number | null | (() => number | null)) => voidDeclarative setInterval. A number sets a fixed interval, null PAUSES, and a getter () => number | null makes the delay REACTIVE (an effect restarts/pauses the timer when the returned value changes). Auto-cleared on unmount. Returns nothing.
Example
const paused = signal(false)
useInterval(() => tick(), () => paused() ? null : 1000)Common mistakes
Passing
delay: 0expecting a pause — usenullto pause;0runs as fast as the event loop allows.Expecting a NUMBER delay to react to a signal — only the getter form (
() => number | null) is reactive; a plain number is read once at setup.Relying on a fresh
callbackper render — the callback is captured once (Pyreon bodies run once); read reactive values INSIDE the callback.
See also: useTimeout · useIdle
useTimeout hook
useTimeout(callback: () => void, delay: number | null) => { reset: () => void; clear: () => void }Declarative setTimeout that STARTS immediately at setup (fires once after delayms unless delay is null). Returns reset (restart with the original delay) / clear (stop). Auto-cleared on unmount.
Example
const t = useTimeout(() => hideToast(), 3000)
<div onMouseEnter={t.clear} onMouseLeave={t.reset}>…</div>Common mistakes
Expecting it to be lazy — it fires ON MOUNT automatically; pass
delay: nullto disable, or callclear().callbackanddelayare captured once at setup —reset()reuses the original delay; there is no way to change the delay after creation.
See also: useInterval · useDebouncedValue
useDebouncedCallback hook
useDebouncedCallback<T extends (...args: any[]) => any>(callback: T, delay: number) => T & { cancel: () => void; flush: () => void }Returns a debounced wrapper that resets a timer on each call and invokes callback after delayms of quiet, plus .cancel() (drop the pending call) and .flush() (invoke now with the last args). Pending timer auto-cancelled on unmount.
Example
const onSearch = useDebouncedCallback((q: string) => fetchResults(q), 300)
<input onInput={(e) => onSearch(e.target.value)} />Common mistakes
Relying on the "always latest callback" behavior the JSDoc claims — the callback is captured ONCE at setup (Pyreon component bodies run once), so read reactive values INSIDE the callback rather than expecting a new callback identity to take effect.
Re-creating it per render in a loop — define it once at component setup; a fresh debouncer per call resets the timer every time.
See also: useThrottledCallback · useDebouncedValue
useThrottledCallback hook
useThrottledCallback<T extends (...args: any[]) => any>(callback: T, delay: number) => T & { cancel: () => void }Returns a throttled wrapper (rate-limited to once per delayms; leading + trailing edge, latest-args) with a .cancel() method. Auto-cancelled on unmount. Use over debounce when you want steady updates during a continuous stream (scroll, drag).
Example
const onScroll = useThrottledCallback(() => updateParallax(), 16)Common mistakes
Same "latest callback" caveat as
useDebouncedCallback— the callback is captured once; read reactive values inside it.Reaching for throttle when you want the value to settle AFTER the burst — that is debounce (
useDebouncedCallback); throttle fires DURING the burst at a fixed rate.
See also: useDebouncedCallback
useLatest hook
useLatest<T>(value: T) => { readonly current: T }Wraps value in a mutable { current } ref object. Does NOT auto-update — it captures once (Pyreon bodies run once); the caller must update .current manually or pass a reactive getter as the value.
Example
const latest = useLatest(props.onSave)
// later, in a stale-closure-prone callback: latest.current?.()Common mistakes
Expecting
.currentto auto-track a signal — it is set once from the argument. To keep it fresh, assignlatest.current = …in an effect, or store a getter and calllatest.current().
See also: usePrevious
useKeyboard hook
useKeyboard(key: string, handler: (event: KeyboardEvent) => void, options?: { event?: 'keydown' | 'keyup'; target?: EventTarget }) => voidRegisters a keydown (or keyup) listener on options.target (default document) that fires handler only when event.key === key. Auto-removed on unmount. For app-wide shortcuts with modifiers, prefer @pyreon/hotkeys.
Example
useKeyboard('Escape', () => closeModal())Common mistakes
Expecting modifier handling — it matches
event.keyEXACTLY (no Cmd/Ctrl/Shift logic); use@pyreon/hotkeysfor chords.options(event / target) is read once at setup — a later change to the target is not re-bound.
See also: useEventListener · useClickOutside
useScrollLock hook
useScrollLock() => { lock: () => void; unlock: () => void }Lock/unlock body scroll (sets document.body.style.overflow = "hidden"). Uses a MODULE-LEVEL reference count so concurrent locks (nested modals) compose — the saved overflow restores only when the last lock releases. SSR-safe (both no-op on the server); an unmount while still locked auto-unlocks.
Example
const { lock, unlock } = useScrollLock()
onMount(() => { lock(); return unlock })Common mistakes
Expecting one instance to NEST — a per-instance
isLockedguard makes repeatlock()/unlock()calls no-ops, so one instance holds at most ONE refcount unit (an extraunlock()can never release another component's lock); use a separateuseScrollLock()per independently-lifecycled lock.Setting
body { overflow }yourself while a lock is active — the hook restores the value captured at the 0→1 transition, clobbering your change on release.
See also: useDialog · useClickOutside
useHaptics hook
useHaptics() => { impact: (style?: 'light' | 'medium' | 'heavy' | 'soft' | 'rigid') => void; notification: (type: 'success' | 'warning' | 'error') => void; selection: () => void }Imperative haptic feedback. Fire-and-forget methods that call navigator.vibrate on web (mapped patterns) and lower to native PyreonHaptics under PMTC. impact defaults to medium.
Example
const haptics = useHaptics()
<button onClick={() => { haptics.impact('light'); submit() }}>Pay</button>Common mistakes
Expecting feedback on desktop / unsupported browsers — it silently no-ops when
navigator.vibrateis absent (iOS Safari has no web vibrate); the real device feedback comes from the native PMTC target.
See also: useShare · useNotifications
useShare hook
useShare() => { text: (text: string) => void; url: (url: string) => void; textUrl: (text: string, url: string) => void; canShare: () => boolean }Imperative Web Share API wrapper (lowers to native PyreonShare under PMTC). canShare() feature-detects navigator.share; the share methods no-op where it is unavailable.
Example
const share = useShare()
<Show when={() => share.canShare()}>
<button onClick={() => share.url(location.href)}>Share</button>
</Show>Common mistakes
Expecting to detect a user CANCEL — the rejection from
navigator.shareis swallowed (no promise is returned), so a cancelled share surfaces nothing. Gate the button oncanShare()and treat the call as fire-and-forget.
See also: useHaptics · useLinking
useLinking hook
useLinking() => { openUrl: (url: string) => void }Imperative external-link opener. openUrl calls window.open(url, "_blank", "noopener,noreferrer") on web and lowers to native PyreonLinking under PMTC. SSR-safe.
Example
const { openUrl } = useLinking()
<button onClick={() => openUrl('https://pyreon.dev')}>Docs</button>Common mistakes
Expecting configurable target/features — it always opens a new tab with
noopener,noreferrerhard-coded. For in-app navigation use@pyreon/router, not this.
See also: useShare
useNotifications hook
useNotifications() => { requestPermission: () => void; notify: (title: string, body: string) => void }Imperative LOCAL notifications (Web Notifications API; lowers to native PyreonNotifications under PMTC). notify auto-requests permission on first use; requestPermission prompts ahead of time.
Example
const notifications = useNotifications()
onMount(() => notifications.requestPermission())
notifications.notify('Done', 'Your export is ready')Common mistakes
Expecting
notifyto appear synchronously on first call — when permission is undecided it requests first and posts only AFTER the async grant (or never, if denied). CallrequestPermission()ahead of time for immediate notifications.These are LOCAL notifications only — not push; there is no server/remote delivery.
See also: useHaptics
useFilePicker hook
useFilePicker() => { pick: (accept?: readonly string[]) => Promise<string | null>; isAvailable: () => boolean }Pick a document/file from the device — UIDocumentPickerViewController (iOS), the Storage Access Framework OpenDocument (Android), a hidden file input (web). The document sibling of useImagePicker (any file — a PDF, a .csv, a .zip — not just photos), and the THIRD async-result hook: pick() returns a Promise<string | null> you await, resolving a URI string or null when the user cancels; it never rejects. pick(accept) limits the choice in the <input accept> vocabulary — MIME types (application/pdf), wildcards (image/*), extensions (.csv) — mapped to UTTypes on iOS and MIME types on Android; an entry a platform cannot resolve widens that pick to any file rather than hiding the one the user wants. Under PMTC the async-await lowering wraps the awaiting handler in a Swift Task { … } / Kotlin pyreonAsyncScope.launch { … }. Requires NO storage permission on either native platform — both system pickers run out of process and hand back only the chosen document, so there is no iOS entitlement and no Android runtime permission. Saving/exporting a file is a separate native flow and is intentionally out of scope (tracked follow-up).
Example
const files = useFilePicker()
const status = signal<'idle' | 'picked' | 'cancelled'>('idle')
<button onClick={async () => {
const uri = await files.pick(['application/pdf', '.csv'])
status.set(uri === null ? 'cancelled' : 'picked')
}}>Pick a PDF or CSV</button>Common mistakes
Treating
acceptas a security boundary. It is a hint to the picker: a platform that cannot resolve an entry widens to any file, and a user can rename a file. Validate the type of what you read.Testing the result for TRUTHINESS (
uri ? … : …) instead of comparing to null (uri === null). JS truthiness is not a native Bool — the explicit null comparison is what PMTC lowers touri == nil(Swift) /uri == null(Kotlin), and it is also correct on the web.Calling
files.pick()WITHOUTawaitinside a plain (non-async) handler — it returns aPromise<string | null>, not a URI. Mark the handlerasyncandawaitit (PMTC wraps that async handler in a nativeTask/coroutine scope; a sync action slot cannot await).Reaching for
useFilePickerwhen you specifically want a PHOTO. UseuseImagePicker— it opens the photo picker (PHPickerViewController/PickVisualMedia), which is a better UX for images than the general document browser.Treating the returned URI as a stable, persistable path. It is an opaque, platform-shaped, EPHEMERAL handle — a
file://temp copy on iOS, acontent://URI on Android, ablob:object URL on the web. Read it or upload it promptly; do not store it and expect it to resolve later.Requesting a storage permission before calling
pick(). Neither platform needs one — asking is a policy liability (App Store / Play review scrutiny) for zero benefit, and is exactly what the out-of-process pickers exist to avoid.Expecting
pick()to also SAVE/write a file. It only opens (reads) a document; writing/exporting is a separate native flow (iOS export picker, AndroidCreateDocument+ a write step) and a tracked follow-up.
See also: useImagePicker · useShare
useImagePicker hook
useImagePicker() => { pick: () => Promise<string | null>; isAvailable: () => boolean }Pick an image from the device's photo library — PHPickerViewController (iOS), the Android Photo Picker (PickVisualMedia), a hidden file input (web). The SECOND async-result hook (after useBiometrics): pick() returns a Promise<string | null> you await, resolving a URI string or null when the user cancels; it never rejects. Under PMTC the async-await lowering wraps the awaiting handler in a Swift Task { … } / Kotlin pyreonAsyncScope.launch { … }. Requires NO photo-library permission on either native platform — both system pickers run out of process and hand back only the chosen asset, so there is no Info.plist usage description and no Android runtime permission to request.
Example
const picker = useImagePicker()
const status = signal<'idle' | 'picked' | 'cancelled'>('idle')
<button onClick={async () => {
const uri = await picker.pick()
status.set(uri === null ? 'cancelled' : 'picked')
}}>Pick a photo</button>Common mistakes
Testing the result for TRUTHINESS (
uri ? … : …) instead of comparing to null (uri === null). JS truthiness is not a native Bool — the explicit null comparison is what PMTC lowers touri == nil(Swift) /uri == null(Kotlin), and it is also correct on the web (an empty-string URI is not a cancellation).Calling
picker.pick()WITHOUTawaitinside a plain (non-async) handler — it returns aPromise<string | null>, not a URI. Mark the handlerasyncandawaitit (PMTC wraps that async handler in a nativeTask/coroutine scope; a sync action slot cannot await).Treating the returned URI as a stable, persistable path. It is an opaque, platform-shaped, EPHEMERAL handle — a
file://temp copy on iOS, acontent://URI on Android, ablob:object URL on the web. Hand it to an image view or an upload; do not store it and expect it to resolve later.Requesting a photo-library permission before calling
pick(). Neither platform needs one — asking for it is a policy liability (App Store / Play review scrutiny) for zero benefit, and is exactly what the out-of-process pickers exist to avoid.Expecting a cancellation to reject. It resolves
null, so atry/catcharoundpick()will never see the cancel path — branch on the result instead.Forgetting to revoke the web object URL when picking repeatedly in a long-lived view. The web fallback returns
URL.createObjectURL(file); callURL.revokeObjectURL(uri)once the image is no longer displayed if you pick many times, or the blobs accumulate for the page lifetime.
See also: useBiometrics · useShare · fileFromUri
fileFromUri function
fileFromUri(uri: string, options?: { name?: string; type?: string }) => Promise<File>Turn the URI a picker or the camera resolved into a file a request can send. A URI is a HANDLE, not the file: passing it straight into a request body sends the URI's text on every platform. Web: the picker's own File (real name and type) when the URI came from useImagePicker / useFilePicker / useCamera, else the bytes read through fetch (blob:, data:, same-origin). Under PMTC it lowers to PyreonUploadFile.from (Android reads through the app's ContentResolver), and a @pyreon/http request carrying it in multipart or as its body STREAMS the file from disk when sent — a large video is never read into memory. Rejects when the URI cannot be read.
Example
const picker = useImagePicker()
const uploadAvatar = api.endpoint('POST /me/avatar')
const change = async () => {
const uri = await picker.pick()
if (uri === null) return
await uploadAvatar({ multipart: { avatar: await fileFromUri(uri, { name: 'avatar.jpg' }), caption: caption() } })
}Common mistakes
Putting the picker URI itself in the body (
multipart: { avatar: uri }). That sends the URI as a TEXT part on every platform — the server receives a path string, not the image.Calling it without
await. On the web it returns a Promise, and a Promise in a body is sent as[object Promise]; PMTC names the unawaited call instead of lowering it.Relying on the default filename. A native picker hands back a temporary copy whose name is not always the original (an iOS photo arrives as a UUID-named
.jpg), so passnamewhen the server cares — that is what makes every platform send the same part.Resolving the file long before the request. It is a handle to an ephemeral picker copy; resolve and send it in the same handler.
See also: useImagePicker · useFilePicker · useCamera
useBiometrics hook
useBiometrics() => { authenticate: (reason: string) => Promise<boolean>; isAvailable: () => boolean }A biometric authentication gate — Face ID / Touch ID (iOS LAContext), BiometricPrompt (Android), feature-detected on the web. The FIRST @pyreon/hooks service with an ASYNC RESULT: authenticate(reason) returns a Promise<boolean> you await. Under PMTC this lowers to the native biometric APIs, and the async-await lowering wraps the awaiting handler in a Swift Task { … } / Kotlin pyreonAsyncScope.launch { … }. WEB v1: a real assertion is a WebAuthn ceremony (needs a server-issued challenge + a registered credential), so the web authenticate resolves false and isAvailable feature-detects window.PublicKeyCredential — native is the primary target.
Example
const bio = useBiometrics()
const status = signal<'idle' | 'unlocked' | 'denied'>('idle')
<button onClick={async () => {
const ok = await bio.authenticate('Unlock your vault')
status.set(ok ? 'unlocked' : 'denied')
}}>Unlock</button>Common mistakes
Calling
bio.authenticate(...)WITHOUTawaitinside a plain (non-async) handler — it returns aPromise<boolean>, not a boolean, so the gate never applies. Mark the handlerasyncandawaitthe result (PMTC wraps that async handler in a nativeTask/coroutine scope).Expecting the WEB
authenticateto actually authenticate — v1 resolvesfalse(a real WebAuthn assertion needs a server challenge + a registered credential, out of scope for a client-only hook). For web biometric auth drive the WebAuthn API with your backend; the native paths (Face ID / Touch ID / BiometricPrompt) are the real gate.Treating a
falseresult as an error —authenticatenever rejects; failure, cancellation, and an unavailable / unenrolled device all resolvefalse. Branch on the boolean, do not wrap it intry/catch.
See also: useNotifications · useShare
useGeolocation hook
(options?: { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number }) => { latitude: number | null; longitude: number | null; accuracy: number | null; error: string | null; isTracking: boolean; start(): void; stop(): void }Reactive device position, shared across web / iOS / Android — the web half of the hook PMTC has always lowered natively to PyreonGeolocation. Returned fields are GETTERS over signals (not plain values, so a component body reading geo.latitude re-reads on every access, matching the native @Observable/mutableStateOf container), and field NAMES mirror the native container exactly so one shared .tsx reads the same members on all three targets. start() begins navigator.geolocation.watchPosition; stop() clears it and also runs automatically on unmount. HONEST PLATFORM GAP: start() compiles on web and iOS only — Kotlin's native container needs a host closure argument (no default location transport), so geo.start() does not compile on Android; the reactive READS (latitude/longitude/accuracy) are shared on all three, only starting the watch is not.
Example
const geo = useGeolocation({ enableHighAccuracy: true })
<Stack>
<span>{geo.latitude ?? 'no fix yet'}</span>
<Button onPress={() => geo.start()}>Locate</Button>
</Stack>Common mistakes
Calling
geo.start()on Android in shared code without a<NativeIOS>/<Web>guard — the Kotlin container needs a registration closure the shared call site does not supply, so it will not compile there until the Android side grows a default transportDestructuring
{ latitude }at setup instead of readinggeo.latitudeinside JSX/an effect — the fields are getters over signals; destructuring captures the value ONCE and freezes itForgetting
stop()is idempotent by design — calling it beforestart(), or twice, is always safe (it also runs automatically on unmount)
See also: useMap · useWebSocket · useOnline
useMap hook
() => { camera: PyreonMapCamera; markers: PyreonMapMarker[]; selectedMarkerId: string | null; selectedMarker: PyreonMapMarker | null; setCamera(c): void; moveTo(lat, lng, zoom?): void; setMarkers(m[]): void; addMarker(m): void; removeMarker(id): void; selectMarker(id): void }Map STATE — camera, markers, selection — shared across web / iOS / Android, mirroring the native PyreonMapState container field-for-field. Deliberately NOT a renderer: the actual drawing is MapKit / the Android Maps SDK natively, and on web this hook imposes no mapping-library choice — feed map.camera/map.markers to Leaflet, MapLibre, Google Maps, or a plain <svg>. Semantics worth knowing: addMarker UPSERTS by id and preserves the existing list position; removeMarker clears the selection if the removed marker was selected; moveTo keeps the CURRENT zoom when zoom is omitted; selectedMarker is DERIVED from selectedMarkerId, never stored separately. There is no error field by design — the container performs no I/O and cannot fail.
Example
const map = useMap()
map.setCamera({ latitude: 51.5, longitude: -0.12, zoom: 12 })
map.addMarker({ id: 'a', latitude: 51.5, longitude: -0.12, title: 'Here' })
<Show when={() => map.selectedMarker}>{(m) => <span>{m.title}</span>}</Show>Common mistakes
Expecting this hook to render a map — it is pure state; wire
map.camera/map.markersinto your mapping library of choice (or MapKit/Android Maps natively)Calling
moveTo(lat, lng, 0)to reset zoom —0is a valid zoom value, not "unset"; omit the third argument entirely to keep the current zoomExpecting an
errorfield — none exists on any target; the container never performs I/O
See also: useGeolocation
useWebSocket hook
(url: string) => { lastMessage: string | null; messages: string[]; isConnected: boolean; error: string | null; connect(): void; send(text): void; close(): void }A live TEXT socket, shared across web / iOS / Android, mirroring the native PyreonWebSocket container field-for-field (an implicit auto-connect-on-mount is synthesized on native; on web call connect() — or read isConnected/lastMessage, which start at their empty defaults). Getters over signals: ws.isConnected re-reads on every access rather than freezing at mount. HONEST LIMITS matching the native container exactly: TEXT frames only (a binary frame is silently ignored — the native side can never produce one); no automatic reconnect/backoff on any target; messages grows WITHOUT BOUND like the native [String] — a long-lived feed should read lastMessage and keep its own bounded history. error is a rendered STRING (not an Error) to match what the native optional-interpolation can produce.
Example
const ws = useWebSocket('wss://example.com/chat')
onMount(() => ws.connect())
<Show when={() => ws.isConnected}><span>{ws.lastMessage}</span></Show>
<Button onPress={() => ws.send('ping')}>Ping</Button>Common mistakes
Sending a binary payload (
ArrayBuffer/Blob) —send()is TEXT-only; a binary frame received from the server is silently ignored rather than stringifiedRendering
ws.messagesdirectly for a long-lived feed — it grows without bound on every target; keep your own bounded history and readlastMessagefor the latestExpecting automatic reconnect after a drop — none exists on web OR native; wire your own retry against
ws.error/ws.isConnected
See also: useFetch · useOnline
useAuth hook
<User>() => { status: "signedOut" | "signingIn" | "signedIn" | "error"; user: User | null; error: string | null; isAuthenticated: boolean; isSigningIn: boolean; beginSignIn(): void; signInSucceeded(user): void; signInFailed(failure): void; signOut(): void }The device-proven auth-STATE container, shared across web / iOS / Android — mirrors the native PyreonAuth<User> field-for-field, including the exact status string spellings ("signedOut"/"signingIn"/"signedIn"/"error") so <Text>{auth.status}</Text> renders identically on all three targets. Pure state machine — no I/O, no platform edge: the sign-in MECHANISM (an OAuth redirect, a POST, a biometric unlock) lives in your own code and drives the container through its explicit transitions. Non-obvious transition rules, each mirroring the native container line-for-line: beginSignIn keeps the PRIOR user (a token refresh while signed in must not blank the UI); signInFailed also keeps user (a failed refresh keeps the existing session visible); signInSucceeded/beginSignIn/signOut all clear error. Compose token persistence with useSecureStorage (store on signInSucceeded, clear on signOut).
Example
const auth = useAuth<{ id: string; name: string }>()
const signIn = async () => {
auth.beginSignIn()
try {
const user = await api.login()
auth.signInSucceeded(user)
} catch (err) {
auth.signInFailed(err)
}
}
<Show when={() => auth.isAuthenticated}><span>{() => `Hi ${auth.user?.name}`}</span></Show>Common mistakes
Assuming
userisnullwhilestatus === "signingIn"— it is not; a token refresh keeps the PRIOR user visible so the UI does not blank during re-authTreating
status === "error"as terminal —errorcan also be set withuserstill populated (a failed refresh); checkisAuthenticatedfor "does the app have a usable session", not the absence of an errorReimplementing session persistence by hand — pair
signInSucceeded/signOutwithuseSecureStorage, notuseStorage(plaintext) or a module-level variable (lost on reload)
See also: useSecureStorage · useBiometrics
usePush hook
() => { token: string | null; lastNotification: PyreonPushNotification | null; notifications: PyreonPushNotification[]; isAuthorized: boolean; isRegistered: boolean; error: string | null; tokenReceived(t): void; notificationReceived(n): void; authorize(ok): void; fail(err): void; start(register): () => void; stop(): void }Push-notification STATE + INJECTED REGISTRATION, shared across web / iOS / Android, mirroring the native PyreonPushNotifications container. The device token cannot arrive through anything the container owns — natively it lands in the AppDelegate/FirebaseMessagingService, on web it comes out of a service-worker PushManager.subscribe() flow your app orchestrates — so start(register) hands your app a set of handler thunks (onToken, onAuthorization, onNotification) that drive the pure transitions; your code wires the actual SDK/permission-prompt call. start is IDEMPOTENT (a second call while registered does not re-invoke register); stop is safe when never started and safe to call twice. Transition rules to know: tokenReceived clears error; notificationReceived/authorize do NOT touch it; fail keeps the prior token/notifications (stale-while-error).
Example
const push = usePush()
onMount(() => push.start((handlers) => {
Notification.requestPermission().then((p) => handlers.authorize(p === 'granted'))
// subscribe via your service worker, then: handlers.tokenReceived(subscription)
return () => {} // teardown
}))
<Show when={() => push.isAuthorized}><span>{push.lastNotification?.title}</span></Show>Common mistakes
Expecting
start()to request permission or subscribe for you — it only wires the STATE transitions; the app supplies the real SDK calls inside theregistercallbackCalling
start(register)a second time expecting it to re-register — it is idempotent while already registered; callstop()first if you genuinely need to re-run the registration flowReading
tokenbeforeauthorize(true)/tokenReceivedhave fired — both startnull/empty until your registration callback reports them
See also: useNotifications · useAuth
usePayments hook
() => { products: PyreonProduct[]; ownedProductIds: ReadonlySet<string>; purchasing: string | null; error: string | null; owns(id): boolean; productsLoaded(p[]): void; purchaseStarted(id): void; purchaseSucceeded(id): void; purchaseFailed(err): void; restored(ids): void; connect(actions): void; purchase(id): void; restore(): void }In-app-purchase STATE + INJECTED STORE ACTIONS, shared across web / iOS / Android, mirroring the native PyreonPayments container. The purchase MECHANISM (StoreKit/Play Billing natively; Stripe/Paddle/the Payment Request API on web) is async and app-orchestrated — connect(actions) hands the container your { purchase, restore } implementations, and purchase(id)/restore() route through them after entering the purchasing state. purchase(id) is a TOTAL no-op when not connected — it does not even enter the purchasing state. connect is idempotent (a second call while already connected is a no-op). price is a pre-formatted STRING (the store formats it per storefront) on every target.
Example
const pay = usePayments()
onMount(() => pay.connect({
purchase: (id) => store.buy(id).then(() => pay.purchaseSucceeded(id)).catch((e) => pay.purchaseFailed(e)),
restore: () => store.restorePurchases().then((ids) => pay.restored(ids)),
}))
<Button disabled={() => pay.owns('pro')} onPress={() => pay.purchase('pro')}>Buy Pro — {product.price}</Button>Common mistakes
Calling
purchase(id)beforeconnect(actions)— it is a total no-op (does not even setpurchasing) rather than an error, so a missingconnect()call silently does nothingAssuming
purchaseSucceededclearserror— it does not; onlyproductsLoaded/purchaseStarted/restoredclear itCarrying
priceas a number and formatting it yourself — it is already a localized, pre-formatted string on every target; reformatting risks disagreeing with the storefront
See also: useAuth · useSecureStorage
useDatabase hook
() => { insert(collection, record): void; get(collection, id): PyreonRecord | null; all(collection): PyreonRecord[]; delete(collection, id): boolean; find(collection, field, equals): PyreonRecord[]; count(collection): number }A tiny, SYNCHRONOUS document store, shared across web / iOS / Android, mirroring the native PyreonDatabase container (file-backed on both native targets — records survive relaunch). The API is synchronous because the native one is (get returns PyreonRecord?, not a promise) — that rules out IndexedDB for the web half, so localStorage backs it: synchronous, persistent across reloads, ~5 MB per origin. This is for small app-state record sets, NOT a real database: find is a linear scan (as it is natively), and every PyreonRecord.fields value is a STRING on every target ([String: String] natively) — serialize numbers/dates yourself.
Example
const db = useDatabase()
db.insert('notes', { id: '1', fields: { title: 'Hello', done: 'false' } })
const notes = db.all('notes')
const open = db.find('notes', 'done', 'false')Common mistakes
Storing a number or boolean directly in
fields— every value is a STRING on every target (native[String: String]); serialize (String(n)) on write, parse on readExpecting
get/findto be async — the whole point is a SYNCHRONOUS API matching the native container; noawait, no promiseUsing this for a real dataset — it is
localStorage-backed (~5 MB/origin) with a linear-scanfind, meant for small app-state record sets, not a production database
See also: useSecureStorage · useStorage (in @pyreon/storage)
useCrashReporter hook
() => { lastCrash: string; hadCrash: boolean; recordError(message): void; breadcrumb(message): void; clear(): void; start(): void }The web half of the cross-platform crash-reporter container (PyreonCrashReporter natively). Captures via window.onerror + unhandledrejection, persists to localStorage (the durable, cross-reload analogue of the native file/Keychain backing), and rehydrates the previous session's report on start(). start() installs the global-error hooks and is auto-called by the native emit on mount; on web call it once yourself (typically in onMount) — or skip it entirely if you only use recordError for manual capture. breadcrumb(message) maintains a ring buffer capped at 32 entries, attached to the next report. The vendor transport (actually uploading a report) is app-wired via setCrashTransport, mirroring the native transport registry — the hook itself only captures + persists, never fakes an upload. SSR-safe: every read returns the empty state and start() no-ops without window.
Example
const crash = useCrashReporter()
onMount(() => crash.start())
<Show when={() => crash.hadCrash}>
<Banner onDismiss={() => crash.clear()}>We're sorry — the app crashed last time.</Banner>
</Show>
// Elsewhere, in a top-level catch:
crash.recordError(String(err))Common mistakes
Forgetting to call
start()on web — unlike native (auto-called by the emit), the web half needs an explicitstart()call (typically inonMount) to install the global-error hooks and rehydrateExpecting
recordError/breadcrumbto upload anywhere — capture + persist is all this hook does; wiresetCrashTransportseparately to actually send reportsCalling
clear()before the user has seenhadCrash— it wipes both the in-memory state AND the persisted entry, so the banner cannot be shown again after a reload
See also: useOnline
useAppState hook
() => () => "active" | "background" | "inactive"Reactive app lifecycle phase — returns an ACCESSOR (call it in a reactive scope: state()), mirroring the native lifecycle channels (SwiftUI ScenePhase/UIApplication notifications, Android ProcessLifecycleOwner) so one shared source reads the same value on web + iOS + Android. "active" = foreground and focused; "inactive" = visible but not focused (another window focused, or mid-transition); "background" = hidden (tab switched away, window minimized, app backgrounded). SSR-safe: reports "active" on the server. Driven by visibilitychange/focus/blur on web.
Example
const state = useAppState()
// Pause a live poll while the app isn't in the foreground:
<Show when={() => state() === 'active'}><LivePoll /></Show>Common mistakes
Reading
useAppState()()(calling the accessor eagerly at setup) instead of inside a reactive scope — the whole point is that JSX/effectre-reads it on every phase changeTreating
"inactive"as equivalent to"background"— it is not;"inactive"means still VISIBLE but not focused, which matters for pausing input handling without also pausing rendering
See also: useOnline · useDocumentVisibility
setCrashTransport function
(send: ((report: string) => void) | undefined) => voidRegister (or clear, with undefined) the function that actually uploads a crash report string — the app-wired vendor transport for useCrashReporter, mirroring the native PyreonCrashTransportRegistry. useCrashReporter only captures + persists; nothing is ever sent anywhere until a transport is registered.
Example
setCrashTransport((report) => fetch('/api/crashes', { method: 'POST', body: report }))Common mistakes
Never calling this and expecting
useCrashReporterto upload reports on its own — capture/persist and transport are deliberately separate; without a registered transport, reports stay local only
See also: useCrashReporter
Package-level notes
Use
useControllableStatefor controlled/uncontrolled — never reimplement:useControllableState({ value, defaultValue, onChange })is the canonical controlled/uncontrolled pattern. Every primitive in@pyreon/ui-primitivesuses it. Reimplementing theisControlled + signal + gettershape by hand was the #1 anti-pattern across primitives before the helper landed. Passvalueas a FUNCTION (() => props.checked) so the controlled prop read tracks reactively;defaultValueis a PLAIN value captured once as the uncontrolled initial.
Hooks return signals, not plain values: Every hook returns
Signal<T>/Computed<T>/ accessor objects — never plain values. Read by calling:size().width,bp().md,online(). This is the cost of fine-grained reactivity but the reward is composition: hooks chain intoeffect/computeddirectly without re-bridging into Pyreon's reactivity graph.
SSR-safe by construction: Every hook that touches a browser API (
window,document,navigator,IntersectionObserver,ResizeObserver,MediaQueryList) is guarded so SSR returns a sensible default and the listener is registered insideonMount. Do not wrap hook calls inif (typeof window !== "undefined")— the hook does it for you, and your wrapper would skip the hook on the SSR-rendered shell where it should still register no-op state.
Auto-cleanup on unmount — never call
addEventListenerdirectly: Every observer/listener/timer hook (useEventListener,useClickOutside,useElementSize,useIntersection,useInterval,useTimeout,useIdle, etc.) registers anonUnmountcleanup. In primitives, never reach for rawaddEventListener/removeEventListener— useuseEventListener. The framework lint rulespyreon/no-raw-addeventlistenerandpyreon/no-raw-setintervalflag direct DOM listener / timer registration in component code.
useBreakpointreads the theme,useMediaQueryis raw:useBreakpoint()readstheme.breakpointsso swapping themes (or unit systems) Just Works — use it for layout decisions tied to the design system.useMediaQuery("(max-width: 640px)")is a raw media-query escape hatch — use it for one-off queries that don't correspond to a theme breakpoint ((prefers-contrast: more),(orientation: landscape), etc.).