@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 — 52 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.
Features
52 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 |
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 |
useBiometrics | hook | A biometric authentication gate — Face ID / Touch ID (iOS LAContext), BiometricPrompt (Android), feature-detected on t |
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.
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 semantics
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) => { 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 throw
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.
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
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: () => 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. 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()
status.set(uri === null ? 'cancelled' : 'picked')
}}>Pick a file</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.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
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
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.).