Early prerelease React hooks library designed for React.
This package is not published to npm yet. Consume it from this repository only until publishing is authorized.
- Version:
0.1.0-beta.1(unreleased) - Module format: ESM-only
- React peer range:
^18.0.0 || ^19.0.0 - Goal: SSR-safe imports with no browser globals required at module evaluation time
- Publishing has not been authorized
Local Storybook documents public package behavior with interactive examples, Controls, Actions, accessibility checks, and interaction tests.
Every example includes collapsible consumer-facing TypeScript code with Show code / Hide code and Copy code controls. Example styling uses Tailwind for documentation only; the hooks package does not require Tailwind.
npm run storybook
npm run build:storybookA future GitHub Pages URL may host the static Storybook build. Deployment is not configured yet.
Invokes a handler when a document-level pointer or click event happens outside a referenced element.
import { useRef, useState } from 'react'
import { useOnClickOutside } from '@muradyanvano/react-hooks'
export function Menu() {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
useOnClickOutside(containerRef, () => {
setOpen(false)
})
return (
<div ref={containerRef}>
<button type="button" onClick={() => setOpen((value) => !value)}>
Toggle menu
</button>
{open ? <div>Menu content</div> : null}
</div>
)
}The toggle control is inside the referenced container so the default pointerdown listener does not close the menu when opening it.
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
When false, no document listener is registered. |
eventType |
'pointerdown' | 'click' |
'pointerdown' |
Document event to listen for. |
capture |
boolean |
true |
Capture-phase listener registration. |
pointerdown(default): registers apointerdownlistener; the handler receives the originalPointerEvent. Pointer Events cover mouse, touch, and pen.click: registers aclicklistener; the handler receives the originalMouseEvent.
- Importing the package does not touch
windowordocument. - The hook registers listeners only in an effect, so server rendering does not attach document listeners.
- Effects clean up correctly under React StrictMode, so duplicate active listeners are not left behind.
- Single ref only (no ref arrays)
- No ignored selectors / ignored elements
- No iframe-specific handling
- Not a full Shadow DOM API (uses
composedPath()when available, thencontains())
Invokes a handler when a referenced element is removed from its owning document tree — either directly or because an ancestor is removed.
import { useEffect, useRef, useState } from 'react'
import { useOnElementRemoval } from '@muradyanvano/react-hooks'
export function ExternalWidgetHost() {
const hostRef = useRef<HTMLDivElement>(null)
const widgetRef = useRef<HTMLDivElement>(null)
const [readyTick, setReadyTick] = useState(0)
useEffect(() => {
const host = hostRef.current
if (host == null) {
return
}
const widget = document.createElement('div')
widget.textContent = 'External widget'
host.append(widget)
widgetRef.current = widget
// Assignment happens in an effect; bump state so the hook re-syncs after commit.
setReadyTick((tick) => tick + 1)
return () => {
widget.remove()
widgetRef.current = null
}
}, [])
useOnElementRemoval(widgetRef, (element) => {
console.log('Widget removed:', element, readyTick)
})
return <div ref={hostRef} />
}| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
When false, no MutationObserver is created or kept. |
- Default
enabledistrue. - Importing the package does not touch
window,document, orMutationObserver. - Observers are created only in effects, so server rendering does not observe the DOM.
The hook is intended for removal performed outside React’s normal ownership flow, or for observing an element from a component that remains mounted. It is not a replacement for React effect cleanup. When the observing component unmounts, React may disconnect the observer before an asynchronous mutation callback runs.
- Single ref only
- No ignore lists
- No public
MutationObserverabstraction - Requires a React commit after imperative
ref.currentassignment so observation can sync
See Storybook (Hooks/useOnElementRemoval) for interactive examples.
Registers a keyboard listener for matching key strokes. Matching uses exact, case-sensitive event.key values (not event.code).
import { useOnKeyStroke } from '@muradyanvano/react-hooks'
useOnKeyStroke('Escape', () => {
closeDialog()
})
useOnKeyStroke(['ArrowUp', 'ArrowDown'], (event) => {
event.preventDefault()
moveSelection(event.key)
})
useOnKeyStroke(
(event) =>
event.key.toLowerCase() === 'k' && (event.ctrlKey || event.metaKey),
(event) => {
event.preventDefault()
openCommandMenu()
},
)Target ref example:
import { useRef } from 'react'
import { useOnKeyStroke } from '@muradyanvano/react-hooks'
export function Region() {
const regionRef = useRef<HTMLDivElement>(null)
useOnKeyStroke('Enter', handleEnter, {
target: regionRef,
})
return (
<div ref={regionRef} tabIndex={0}>
Focus this region and press Enter
</div>
)
}| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
When false, no listener is registered. |
eventType |
'keydown' | 'keyup' |
'keydown' |
Keyboard event to listen for. |
target |
EventTarget | RefObject | null |
window |
Omitted → window. Explicit null → no listen. |
dedupe |
boolean |
false |
When true, ignore event.repeat. |
capture |
boolean |
false |
Capture-phase listener. |
passive |
boolean |
false |
Passive listeners should not rely on preventDefault(). |
- Filters: string, readonly string array,
true(all keys), or predicate. - Predicates are the API for modifier combinations — no
Ctrl+Kstring parser. - Does not auto-ignore inputs/textareas/contenteditable; use a predicate when needed.
- Default
passive: falseallowspreventDefault(). - SSR-safe: no
windowaccess at import; listeners are effect-only. - Imperative target-ref updates need a later React commit to sync.
- No combination-string parsing
- No public editable-target helper
- No
useOnKeyDown/useOnKeyUpaliases - Single active listener per hook instance
See Storybook (Hooks/useOnKeyStroke) for interactive examples.
Registers a DOM event listener with strong native event-map inference. Omitted target defaults to window (resolved inside effects). Returns void — use enabled for declarative control.
import { useRef } from 'react'
import { useEventListener } from '@muradyanvano/react-hooks'
// Default window target (SSR-safe call form)
useEventListener('resize', (event) => {
console.log(event.type)
})
// Explicit document target — evaluating `document` requires a client environment
useEventListener(document, 'visibilitychange', () => {
console.log(document.visibilityState)
})
// Ref target
const buttonRef = useRef<HTMLButtonElement>(null)
useEventListener(buttonRef, 'click', (event) => {
console.log(event.clientX)
})
// Multiple events
useEventListener(buttonRef, ['mouseenter', 'mouseleave'], (event) => {
console.log(event.type)
})
// Custom event (annotate the handler for typed detail)
useEventListener(
buttonRef,
'item:selected',
(event: CustomEvent<{ id: string }>) => {
console.log(event.detail.id)
},
)| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
When false, no listeners are registered. |
capture |
boolean |
false |
Capture-phase listener. |
passive |
boolean |
false |
Passive listeners should not rely on preventDefault(). |
once |
boolean |
false |
Native once — applies per registered event name. |
signal |
AbortSignal |
— | Aborts/removes the listener natively. |
UseEventListenerOptions extends AddEventListenerOptions with enabled. enabled is never passed to the browser.
- Latest handler without listener churn.
- Event-name arrays are deduped; equivalent contents avoid re-registration.
- Explicit
nulltarget registers nothing (does not fall back towindow). - Imperative target-ref updates need a later React commit to sync.
- Accessing
window/documentinside handlers is client-time behavior. - Passing
document/windowas a call argument is not intrinsically SSR-safe — the consumer evaluates that global before the hook runs. Prefer omitted window form, a ref, or a client-only boundary.
- One target per call (no target arrays)
- One handler registration set (no multi-listener sugar beyond event-name arrays)
- No manual cleanup return value
- Existing hooks are not yet implemented on top of
useEventListener
See Storybook (Hooks/useEventListener) for interactive examples.
Invokes a handler after a sustained pointer press on a referenced element. Uses Pointer Events only (pointerdown, pointermove, pointerup, pointercancel). Consumers need an environment with Pointer Events support.
import { useRef, useState } from 'react'
import { useOnLongPress } from '@muradyanvano/react-hooks'
export function HoldToFavorite() {
const [favorited, setFavorited] = useState(false)
const targetRef = useRef<HTMLButtonElement>(null)
useOnLongPress(
targetRef,
() => {
setFavorited(true)
},
{
delay: 500,
onRelease: (details) => {
console.log(details.isLongPress, details.duration, details.distance)
},
},
)
return (
<div>
<button ref={targetRef} type="button" style={{ touchAction: 'none' }}>
{favorited ? 'Favorited' : 'Hold to favorite'}
</button>
<button type="button" onClick={() => setFavorited(true)}>
Favorite with click
</button>
</div>
)
}| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
When false, no target listener is registered. |
delay |
number | ((event: PointerEvent) => number) |
500 |
Hold duration in ms. Function delay is resolved once at pointerdown. |
distanceThreshold |
number | false |
10 |
Cancel pending activation after this Euclidean movement (px). false disables cancellation. |
button |
number |
0 |
Required event.button to start a gesture. |
self |
boolean |
false |
When true, only presses whose event.target === element are accepted. |
preventDefault |
boolean |
false |
Call preventDefault() on accepted pointerdown. |
stopPropagation |
boolean |
false |
Call stopPropagation() on accepted pointerdown. |
capture |
boolean |
false |
Capture-phase registration for the stable target pointerdown listener. |
onRelease |
(details) => void |
— | Called once on matching pointerup with release metrics. |
- Fixed or function delays are supported. Negative delays clamp to
0; non-finite values fall back to500. - Zero delay still schedules asynchronously (does not run inside the pointerdown stack).
- Distance uses
Math.hypotfrom the start coordinates; release reports the maximum distance observed, including pointerup. - Movement past the threshold cancels the pending timer but still reports
onReleasewithisLongPress: falseon later pointerup. distanceThreshold: falsedisables movement cancellation while still reporting distance.
{
element: T
event: PointerEvent // matching pointerup
duration: number // pointerdown → pointerup (ms)
distance: number // maximum Euclidean distance
isLongPress: boolean
}onRelease is not called for pointercancel, blur, unmount, disabled cleanup, or target replacement. Movement cancellation still waits for pointerup to report metrics.
Long press is pointer-specific. Do not use it as the only way to perform an essential action. Provide an equivalent standard control for keyboard users and people who cannot reliably hold a timed press. Destructive actions should include confirmation and an alternative path.
This hook does not suppress the click that a browser may generate after pointerup. Long press and click are separate interactions — consumers that combine both must define their own coordination. preventDefault on pointerdown is not documented as guaranteed click suppression; CSS such as touch-action / user-select may still be needed.
- Importing the package does not touch
window,document,PointerEvent, or timers. - Listeners and timers are created only in effects.
- Effects clean up correctly under React StrictMode (no duplicate listeners/timers).
- Pointer Events only (no separate mouse/touch fallbacks; no polyfill)
- No automatic click suppression
- No keyboard long-press detection
- No
onceoption - No public gesture-state / progress API
- Single active gesture per hook instance
- Imperative
ref.currentupdates need a later React commit to sync
See Storybook (Hooks/useOnLongPress) for interactive examples.
Detects when a user begins typing while focus is outside an editable element. A common use case is focusing a search field when typing starts anywhere on the page.
import { useRef } from 'react'
import { useOnStartTyping } from '@muradyanvano/react-hooks'
export function Search() {
const inputRef = useRef<HTMLInputElement>(null)
useOnStartTyping(() => {
inputRef.current?.focus()
})
return (
<input
ref={inputRef}
type="search"
placeholder="Start typing to search"
aria-label="Search"
/>
)
}The hook does not call preventDefault or stopPropagation, so the initial typed character may continue into a newly focused input (browser-dependent).
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
When false, no document listener is registered. |
isTypedCharacterValid |
(event: KeyboardEvent) => boolean |
ASCII alphanumeric default | Completely replaces the default character-validity decision. |
isFocusedElementEditable |
() => boolean |
DOM editable detector | Completely replaces the default editable-element check. |
Accepts Latin letters A–Z / a–z and digits 0–9. Rejects whitespace, punctuation, symbols, navigation/control keys, empty event.key, Ctrl/Alt/Meta modifiers, event.repeat, and event.isComposing. Shift is allowed so uppercase letters remain valid.
Equivalent rule:
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
!event.isComposing &&
!event.repeat &&
/^[a-z0-9]$/i.test(event.key)A custom isTypedCharacterValid is responsible for any modifier, repeat, and composition filtering it requires. Editable-element protection remains a separate check and always runs first.
By default the handler is skipped when focus is in an <input>, <textarea>, <select>, a contenteditable region, a descendant of one, or an editable control inside an open shadow root (where detectable). Nested contenteditable="false" islands are treated as non-editable.
- Listener is active only while
enabledis true. - If the focused element is editable, stop (validator is not called).
- If the character validator rejects the event, stop.
- Call the latest handler with the original
KeyboardEvent.
- Importing the package does not touch
documentorwindow. - The document
keydownlistener is registered only inuseEffect. - Effects clean up correctly under React StrictMode (one active listener per mounted instance).
- Default validator is ASCII Latin letters and digits only
- Based on
keydown, notbeforeinput/ text-input events - Does not reconstruct IME-composed text
- Does not manage input values
- Initial-character insertion after focus can be browser-dependent
- Not a shortcut hook — use
useOnKeyStrokefor explicit key or shortcut handling
See Storybook (Hooks/useOnStartTyping) for interactive examples.
Provides a reactive list of available media devices through navigator.mediaDevices. Groups cameras, microphones, and speakers; refreshes on devicechange; and offers an explicit permission workflow that immediately stops temporary tracks.
import { useDevicesList } from '@muradyanvano/react-hooks'
export function DevicePicker() {
const { videoInputs, audioInputs, permissionGranted, ensurePermissions } =
useDevicesList()
return (
<section>
{!permissionGranted ? (
<button
type="button"
onClick={() => {
void ensurePermissions()
}}
>
Allow camera and microphone
</button>
) : null}
<p>Cameras: {videoInputs.length}</p>
<p>Microphones: {audioInputs.length}</p>
</section>
)
}Prefer explicit ensurePermissions() after a user gesture. Do not rely on requestPermissions: true as the primary pattern — browsers may block automatic prompts.
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
When false, no enumeration or listener. |
requestPermissions |
boolean |
false |
Best-effort automatic permission after mount. |
constraints |
MediaStreamConstraints |
{ audio: true, video: true } |
Passed to getUserMedia (fresh copy each call). |
onUpdated |
(devices: readonly MediaDeviceInfo[]) => void |
— | Called after successful enumeration. |
| Field | Description |
|---|---|
isSupported |
true when navigator.mediaDevices.enumerateDevices exists. |
devices |
Latest successful enumeration (readonly). |
videoInputs / audioInputs / audioOutputs |
Filtered by device.kind, preserving order. |
permissionGranted |
true only after this hook’s successful getUserMedia attempt. |
isLoading |
true while refresh/permission operations are in flight (supports overlap). |
error |
Normalized Error or null. |
refresh |
Re-enumerate; no-ops when disabled/unsupported; does not throw on failure. |
ensurePermissions |
Request media, stop all tracks, refresh; returns boolean. |
- Device labels/IDs may be empty until permission is granted.
- Permission generally requires a secure context and a user gesture.
permissionGrantedreflects this hook’s latest attempt, not a full Permissions API.- Temporary streams are never stored in React state or attached to elements.
- Unsupported empty state during SSR; no enumeration, permission, or listeners.
- Listeners and async work clean up under Strict Mode; temporary tracks are always stopped.
- Lists devices only — does not open, preview, or switch streams
- Audio-output support varies by browser/platform
devicechangetiming varies- Automatic permission requests may be blocked
- Permission requests cannot be cancelled after
getUserMediabegins
See Storybook (Hooks/useDevicesList) for interactive examples. Most stories use mocks; Live hardware uses real devices for local testing. Pages generally cannot revoke camera/microphone — clear the site permission in browser settings, then remount (or reload) to re-test the prompt.
Manages browser screen capture through navigator.mediaDevices.getDisplayMedia. Prefer an explicit start() call from a button click. The hook owns streams it creates, stops tracks on stop() / replacement / unmount, and synchronizes when the user ends sharing in the browser UI.
import { useEffect, useRef } from 'react'
import { useDisplayMedia } from '@muradyanvano/react-hooks'
export function ScreenShare() {
const videoRef = useRef<HTMLVideoElement>(null)
const { stream, isSharing, isLoading, error, start, stop } = useDisplayMedia()
useEffect(() => {
const video = videoRef.current
if (video == null) {
return
}
video.srcObject = stream
return () => {
video.srcObject = null
}
}, [stream])
return (
<section>
<video
ref={videoRef}
autoPlay
muted
playsInline
aria-label="Screen share preview"
/>
{isSharing ? (
<button type="button" onClick={stop}>
Stop sharing
</button>
) : (
<button
type="button"
disabled={isLoading}
onClick={() => {
void start()
}}
>
{isLoading ? 'Starting…' : 'Start sharing my screen'}
</button>
)}
{error != null ? <p role="alert">{error.message}</p> : null}
</section>
)
}| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
false |
Advanced declarative auto-start. Prefer imperative start() from a click. |
video |
boolean | MediaTrackConstraints |
true |
Passed to getDisplayMedia (read at call time; not mutated). |
audio |
boolean | MediaTrackConstraints |
false |
System-audio request; availability varies by browser, OS, and surface. |
| Field | Description |
|---|---|
isSupported |
true when navigator.mediaDevices.getDisplayMedia is callable. |
stream |
Current owned MediaStream, or null. |
isSharing |
true while an owned stream is active. |
isLoading |
true while a start() request is pending (supports overlap). |
error |
Normalized Error from the latest failed attempt, or null. |
start |
Requests display media; returns the stream or null (never throws). |
stop |
Stops owned tracks, removes listeners, clears stream / isSharing. |
- Default
enabled: falsemeans no automatic screen-sharing request. - Changing
video/audioalone does not restart sharing or re-prompt. - A failed replacement keeps the existing active stream until a later success.
- Overlapping
start()calls: the latest request wins; stale streams are stopped. - Browser “Stop sharing” ends tracks; the hook clears state and stops remaining tracks.
- Cancellation (
NotAllowedError/AbortError) is a normal recoverableerrorstate. - Secure context and a user gesture are typically required for
start().
- Unsupported idle state during SSR; no
getDisplayMedia, listeners, or streams. - Unmount and Strict Mode cleanups stop owned tracks and invalidate pending requests.
- Requires a secure context and browser support for
getDisplayMedia - Users choose the shared surface; apps cannot silently select a screen or window
- System-audio capture varies by browser, OS, and selected surface
getDisplayMediacannot be aborted withAbortSignal- Constraint support varies
- The hook does not record, encode, upload, or transmit captured content
- Declarative
enabledmay be blocked without a user gesture
See Storybook (Hooks/useDisplayMedia) for interactive examples. Live screen sharing uses the real browser chooser; other stories use deterministic mocks for automated tests.
npm install
npm run verifyUseful scripts:
| Script | Purpose |
|---|---|
npm run storybook |
Start local Storybook docs |
npm run build:storybook |
Build static Storybook output |
npm run test:storybook |
Run Storybook browser interaction/a11y checks |
npm run test:ssr:react18 |
Packed-consumer SSR check against React 18 |
npm run build / npm run build:lib |
Build the ESM library and declarations |
npm run typecheck |
TypeScript project build |
npm run lint |
ESLint |
npm run format / npm run format:check |
Prettier |
npm test / npm run test:watch / npm run test:coverage |
Unit tests (Vitest) |
npm run pack:dry-run |
Inspect the future publish tarball |
npm run verify |
Format, typecheck, lint, unit tests, library build, Storybook build |
npm run verify:ci |
verify plus Storybook browser tests and React 18 SSR consumer |
MIT © Vano Muradyan