@onoxm/react-hooks
v0.6.7
Published
React hooks library by React + Typescript
Readme
@onoxm/react-hooks
A collection of React hooks for TypeScript — events, DOM measurement, keyboard shortcuts, theming and countdowns.
All hooks are also re-exported from the
ono-react-elemententry point.
Installation
npm install @onoxm/react-hooks
# or
pnpm add @onoxm/react-hooks
# or
yarn add @onoxm/react-hooksPeer dependency:
react(>= 19).@onoxm/utilsis a regular dependency and is installed automatically.
Hooks overview
| Hook | Purpose |
| ----------------------------------------- | ---------------------------------------------------------------- |
| useEventListener | Attach event listeners with a stable handler reference |
| useClickOutSide | Detect clicks outside one or more elements |
| useCountdown | Countdown timer with start / reset |
| useDefer | Defer expensive rendering by animation frames |
| useGetElementSize | Observe an element's size (or the window's) |
| useKeyPress | Keyboard shortcuts, including combinations |
| useMouseClick | Track the last click position |
| useTheme | Resolve light / dark / system into a boolean |
| useThemePro | Theme switching with a circular reveal animation |
| useWindowSize | Track the window size — ⚠️ deprecated, use useGetElementSize |
API
useEventListener
Attaches an event listener to a target and cleans it up automatically.
useEventListener(event: string | string[], handler: (e: Event) => void, options?: {
target?: EventTarget | RefObject<EventTarget | null> | null | undefined
listenerOptions?: boolean | AddEventListenerOptions
}): voidimport { useRef } from 'react'
import { useEventListener } from '@onoxm/react-hooks'
function Box() {
const ref = useRef<HTMLDivElement>(null)
// Multiple events at once; target defaults to window
useEventListener(['pointerdown', 'pointerup'], e => console.log(e.type), {
target: ref
})
return <div ref={ref} />
}Notes:
targetresolution:null/undefined⇒window; aRefObject⇒ its.current, falling back towindowwhile it is stillnull; otherwise theEventTargetitself.- The handler is kept in a ref and always called with the latest closure, so you do not need
useCallback— a new inline function on every render does not rebind the listener. - An array of events is normalized so that a new array with the same contents does not rebind either.
- Because binding happens inside
useEffect, this hook is safe under SSR.
useClickOutSide
Calls the handler when a click happens outside the given element(s).
useClickOutSide(outsideElement, handler: (e: MouseEvent) => void, options?: {
event?: 'click' | 'contextmenu' | 'mousedown' | 'mouseup' | 'mousemove' | 'touchstart' | 'touchend'
| (…)[]
}): voidfunction Dropdown({ onClose }: { onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null)
useClickOutSide(ref, onClose) // default event: 'click'
useClickOutSide([ref, otherRef], onClose, {
// several elements
event: ['mousedown', 'touchstart']
})
return <div ref={ref}>…</div>
}Notes:
outsideElementaccepts a single element, a ref, or an array of either, and may benull.- The listener is attached to
window; the handler receives the originalMouseEvent. - Duplicate event types throw —
{ event: ['click', 'click'] }raises an error instead of silently listening twice. - If the referenced elements are not mounted yet, no element is found, so every click counts as "outside" and the handler fires immediately.
useCountdown
useCountdown(initialTime?: number /* = 60 */, onFinish?: () => void)
: readonly [remainingTime: number, startCountdown: () => void, resetCountdown: () => void]function Resend() {
const [left, start, reset] = useCountdown(60, () => console.log('done'))
return (
<button disabled={left !== 60} onClick={start}>
{left === 60 ? 'Send code' : `Resend in ${left}s`}
</button>
)
}Notes:
- The countdown starts paused; call
startCountdown()to begin. It ticks once per second. - When it reaches
0, it callsonFinishand automatically resetsremainingTimeback toinitialTime, then stops. - ⚠️
resetCountdown()also invokesonFinish— it stops the timer, restoresinitialTime, and calls the callback. onFinishis read through a ref, so you can pass a new inline function without restarting the timer.
useDefer
Defers expensive rendering by counting animation frames.
useDefer(maxCount?: number /* = 100 */): (n: number) => booleanfunction List({ items }: { items: Item[] }) {
const defer = useDefer()
// Render the heavy tree only after a few frames have elapsed
return defer(2) ? <Heavy items={items} /> : <Skeleton />
}Notes:
- Returns a predicate:
defer(n)istrueonce more thannframes have elapsed since mount. - The internal frame counter stops incrementing at
maxCount, so predicates withn >= maxCountare never satisfied — keepnbelowmaxCount.
useGetElementSize
Observes the size of an element (or the window) via ResizeObserver.
useGetElementSize(target?: ElementSizeTarget): { w: number; h: number }function Card() {
const ref = useRef<HTMLDivElement>(null)
const { w, h } = useGetElementSize(ref)
return (
<div ref={ref}>
{w} × {h}
</div>
)
}target may be an HTMLElement, a RefObject<HTMLElement | null>, a function returning either (useful for lazy lookups), or null.
Notes:
- No
targetat all ⇒ the hook reports the window size and follows window resizes. - A
targetwas passed but the element is not mounted yet ⇒ the hook keeps its current size and waits, and never falls back to the window size. (The previous implementation did, which made the first frame render at full viewport size.) - Sizes come from
borderBoxSizewhen available, falling back tocontentRect. - If the size has not changed, the previous state object is returned, avoiding a re-render.
- One
ResizeObserveris created per component instance and reused across renders; the previous element is unobserved whentargetchanges. - Under SSR, or where
ResizeObserverdoes not exist, it returns{ w: 0, h: 0 }instead of throwing.
useKeyPress
Listens for a key or a key combination.
useKeyPress(
keyFilter: KeyType | KeyType[],
eventHandler: (event: KeyboardEvent, key: KeyType) => void,
options?: {
events?: 'keydown' | 'keyup' | ('keydown' | 'keyup')[]
target?: EventTarget | RefObject<EventTarget | null> | null
exactMatch?: boolean
}
): voidfunction Search() {
useKeyPress('k', () => focusSearch(), { exactMatch: true })
// Combinations: '+'-joined, case-insensitive
useKeyPress('ctrl+s', e => {
e.preventDefault()
save()
})
useKeyPress(['shift+alt+d', 'ctrl+shift+d'], () => toggleDevtools())
// Works on macOS and Windows/Linux alike
useKeyPress('CommandOrControl+p', openPalette)
// Several keys at once, listening to keyup instead
useKeyPress(['Escape', 'Enter'], (e, key) => console.log(key, 'on', e.type), {
events: 'keyup'
})
return <input />
}Notes:
- Matching is case-insensitive — filters are lower-cased before comparison, so
'K'and'k'are the same. - Combinations use
+: two parts (ctrl+s) require one modifier, three parts (ctrl+shift+s) require the combination. Supported modifiers:Ctrl,Shift,Alt,Meta, andCommandOrControl(Ctrlon Windows/Linux,Cmdon macOS). - The handler receives the matched filter entry as written by you as its second argument, e.g.
'ctrl+s'. options.eventsdefaults to'keydown'.options.exactMatch(defaultfalse): for single-key filters, a modifier held down prevents the match. Set it totrueto require the key to match exactly — handy for shortcuts like'k'that should not fire while typingCtrl+K.options.targetdefaults towindow.
useMouseClick
Tracks the position of the last click anywhere on the page.
useMouseClick(): {
clientX: number; clientY: number
screenX: number; screenY: number
pageX: number; pageY: number
}function Cursor() {
const { clientX, clientY } = useMouseClick()
return <div style={{ left: clientX, top: clientY }} />
}Notes:
- All six coordinates start at
0and update on everyclick(attached towindow).
useTheme
Resolves a theme mode into the actual light/dark state and notifies you when it changes.
useTheme(props: {
theme: 'light' | 'dark' | 'system'
onDark: () => void
onLight: () => void
useThreeMode?: boolean
}): { cycleTheme: () => ThemeMode; isDark: boolean }function ThemeWatcher({ theme }: { theme: ThemeMode }) {
const { isDark, cycleTheme } = useTheme({
theme,
onDark: () => console.log('dark applied'),
onLight: () => console.log('light applied')
})
return (
<button onClick={() => setTheme(cycleTheme())}>Next: {cycleTheme()}</button>
)
}Notes:
isDarkis the resolved value — for'system'it follows the operating system, so judge lightness/darkness byisDark, not bytheme.onDark/onLightfire on every application: on mount, wheneverthemechanges, and — while the mode is'system'— whenever the OS preference changes.cycleTheme()only returns the next mode; it does not switch. The order issystem → light → dark → systemwhenuseThreeModeistrue(the default), andlight ↔ darkwhen it isfalse.- System changes are only tracked while
theme === 'system'. - Callbacks are read through refs, so new inline functions do not cause repeated work.
- SSR-safe: the initial
isDarkis derived fromthemealone ('system'counts as light) to avoid a hydration mismatch, and is corrected immediately in a layout effect.
useThemePro
Theme switching with a circular-reveal animation, built on useTheme.
useThemePro(options?: {
initTheme?: ThemeMode // default 'light'; applies on first render only
themeRules?: (isDark: boolean) => void
useThreeMode?: boolean // default true
duration?: number // default 300 (ms)
expandOn?: 'dark' | 'light' // which theme the circular reveal plays on; default 'dark'
}): {
theme: ThemeMode
isDark: boolean
changeTheme: (o: { targetTheme: ThemeMode; element?: HTMLElement; onChange?: () => void }) => void
nextTheme: (o?: { element?: HTMLElement; onChange?: () => void }) => void
}function Toggle() {
const { isDark, nextTheme } = useThemePro({
initTheme: 'system',
themeRules: isDark => {
document.documentElement.classList.toggle('dark', isDark)
// Don't forget this line — see the note below
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'
}
})
return (
<button onClick={e => nextTheme({ element: e.currentTarget })}>
{isDark ? '🌙' : '☀️'}
</button>
)
}Notes:
themeRulesis the single outlet for applying a theme. The default adds/removes adarkclass on<html>; pass your own to write to adataattribute, CSS variables, a store, analytics, and so on.- ⚠️
themeRulesis called on every application, not only on changes: on mount, on everychangeTheme/nextTheme, and on system changes while in'system'mode. It must be idempotent — set the value fromisDarkdirectly; do not write toggle logic, which would invert on the extra call. - ⚠️ Set
document.documentElement.style.colorSchemeyourself. Without it, native scrollbars,<select>dropdowns, date pickers and input autofill stay light in dark mode. elementis the origin of the circular reveal. When omitted, the previous element is reused so that later switches keep animating; only user-initiated switches animate.- System-driven changes are applied silently (no animation) — the stored element belongs to a button that had nothing to do with the system change.
changeTheme/nextThemeare no-ops when the target mode equals the current one, andonChangeis not called in that case.
useWindowSize
⚠️ Deprecated — will be removed in
0.7.x. UseuseGetElementSize()with no argument instead: it is a strict superset (same window tracking, plus element observation, and it skips the re-render when the size did not change).useWindowSizeis now a thin wrapper over it, kept only for backwards compatibility.
useWindowSize(): { width: number; height: number }// Old
function Layout() {
const { width, height } = useWindowSize()
return (
<p>
{width} × {height}
</p>
)
}
// New — same behaviour, superset of features
function Layout() {
const { w, h } = useGetElementSize()
return (
<p>
{w} × {h}
</p>
)
}Notes:
- The returned object is memoized, so it keeps a stable reference while the size is unchanged — safe to use as an effect / memo dependency.
- Because it delegates to
useGetElementSize, resizes that do not change the size no longer trigger a re-render (the old implementation did).
Exports
// Hooks
useEventListener
useClickOutSide
useCountdown
useDefer
useGetElementSize
useKeyPress
useMouseClick
useTheme
useThemePro
useWindowSize // deprecated, will be removed in 0.7.x
// Types
ElementSize
ElementSizeTarget
KeyType
KeyEvent
ThemeMode
ThemeRules
UseThemeProOptions
UseThemeProReturnFeatures
- 🪝 Ten focused hooks, all tree-shakable
- 🔥 Full TypeScript support
- 🧷 Stable handler references — no
useCallbackboilerplate - 🌗 Theme handling with SSR-safe resolution and an optional circular-reveal transition
- 🧯 SSR-safe: no
window/ResizeObserveraccess outside effects
Author
ono
License
MIT
