Hooks

useEventListener

A type-safe window/document/element event listener hook with a latest-ref handler, so it never stale-closures and never rebinds on handler change.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseEventListenerOptions {
  /** Passed straight through as the native addEventListener `passive`. */
  passive?: boolean
  /** Passed straight through as the native addEventListener/removeEventListener `capture`. */
  capture?: boolean
}

type TargetRef<T extends HTMLElement> = React.RefObject<T | null>

// Overload 1: no target, or window passed explicitly — event names map to WindowEventMap.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-event-listener.json

Prompt

Build a React + TypeScript "useEventListener" hook (no dependencies beyond
React; wraps the native DOM addEventListener/removeEventListener).

Contract
- Three overloads, discriminated by `options.target`:
  - `useEventListener<K extends keyof WindowEventMap>(eventName: K, handler:
    (event: WindowEventMap[K]) => void, options?: { target?: Window; passive?:
    boolean; capture?: boolean }): void` — target omitted defaults to `window`.
  - `useEventListener<K extends keyof DocumentEventMap>(eventName: K, handler:
    (event: DocumentEventMap[K]) => void, options: { target: Document;
    passive?: boolean; capture?: boolean }): void`.
  - `useEventListener<K extends keyof HTMLElementEventMap, T extends
    HTMLElement>(eventName: K, handler: (event: HTMLElementEventMap[K]) =>
    void, options: { target: React.RefObject<T | null>; passive?: boolean;
    capture?: boolean }): void`.
- `eventName` and the `event` argument's type are inferred from whichever
  event map matches the resolved `target` — no manual casts at the call site.

Behavior
- The listener attaches inside a `useEffect` whose dependency array is only
  `[eventName, target, passive, capture]` — deliberately excluding `handler`
  and excluding the `options` object itself, so a fresh inline `{ target,
  passive }` object literal passed on every render does not cause a rebind.
- `handler` is written into a ref on every render, via a second, unconditional
  effect with no dependency array. The listener actually registered with the
  DOM is a stable wrapper that always calls `handlerRef.current(event)`, so
  the consumer's latest closure runs on every event without the attach/detach
  effect ever re-executing.
- On unmount, or whenever `eventName`/`target`/`passive`/`capture` change, the
  previous listener is removed (matching `capture`) before a new one attaches.
- If `target` is a `RefObject` whose `.current` is `null` when the effect
  runs, no listener is attached and nothing throws — and it does NOT
  auto-attach later: the ref OBJECT identity never changes, so a target
  that mounts afterwards (conditional rendering) is never picked up. For
  late-mounted targets, remount the consumer with a `key`, or reach for a
  callback-ref-based hook (`use-hover` shows that pattern). Targets that
  exist for the component's whole life — window, document, an
  always-rendered element — are the intended fit.
- The hook body never reads `window`/`document` during render — only inside
  effects — so it's safe to call from a component that also renders on the
  server; the listener simply attaches once the effect runs client-side.

Rendering & styling
- The hook renders nothing and owns no DOM node — there is no markup or
  token to style here. Consumers own all UI built on top of the events it
  reports.

Customization levers
- Add an `AbortSignal`-based variant (`options.signal`) so one signal can tear
  down many listeners at once instead of relying on unmount alone.
- Extend `eventName` to accept an array of event names sharing one handler
  (e.g. `["mousedown", "touchstart"]` for an outside-click hook) when a call
  site needs more than one event wired to the same callback.

Concepts

  • Latest-ref handlerhandler is synced into a ref every render, so the DOM listener (a stable wrapper function) always calls the freshest closure. This is what lets a handler read current props/state without ever going stale, without the listener itself needing to change.
  • Zero-rebind listener — the attach/detach effect depends only on eventName/target/passive/capture, never on handler or the options object's identity. A parent re-rendering with a brand-new inline handler (or a brand-new { target, passive } object literal) does not tear down and reattach the native listener.
  • Target-discriminated event map — three overloads narrow eventName and the event argument's type to WindowEventMap / DocumentEventMap / HTMLElementEventMap based on options.target, so event comes back correctly typed (e.g. MouseEvent, KeyboardEvent) with no manual cast at the call site.
  • SSR-safe effect bodywindow/document are only ever read inside useEffect, never during render, so the hook needs no typeof window guard yet is safe to include in a server-rendered tree.

On This Page