# useEventListener

A hook that attaches a typed event listener with automatic cleanup.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/use-event-listener.json
```

[Registry JSON](https://mwui.vercel.app/r/use-event-listener.json)

## Preview

```tsx
import * as React from "react";

import { useEventListener } from "@/hooks/use-event-listener";

export function Preview() {
  const cardRef = React.useRef<HTMLDivElement>(null);
  const [lastKey, setLastKey] = React.useState("—");
  const [point, setPoint] = React.useState<{ x: number; y: number } | null>(null);

  useEventListener("keydown", (event) => {
    setLastKey(event.key === " " ? "Space" : event.key);
  });

  useEventListener(
    "pointermove",
    (event) => {
      const bounds = cardRef.current?.getBoundingClientRect();
      if (!bounds) return;

      setPoint({
        x: Math.round(event.clientX - bounds.left),
        y: Math.round(event.clientY - bounds.top),
      });
    },
    cardRef,
  );

  useEventListener("pointerleave", () => setPoint(null), cardRef);

  return (
    <div className="flex w-full max-w-sm flex-col gap-3 text-sm">
      <div
        ref={cardRef}
        className="flex h-28 items-center justify-center rounded-lg border border-dashed border-border bg-muted/40 text-muted-foreground"
      >
        {point ? (
          <span className="font-mono text-xs">
            x: {point.x} · y: {point.y}
          </span>
        ) : (
          <span className="text-xs">Move the pointer in here</span>
        )}
      </div>

      <p className="text-xs text-muted-foreground">
        Last key on <span className="font-mono">window</span>:{" "}
        <span className="font-mono text-foreground">{lastKey}</span>
      </p>
    </div>
  );
}
```


## Source

### hooks/use-event-listener.ts

```ts
"use client";

import * as React from "react";

type UseEventListenerOptions = {
  capture?: boolean;
  passive?: boolean;
  once?: boolean;
  enabled?: boolean;
};

type EventTargetLike<T> = T | React.RefObject<T | null> | null | undefined;

function useEventListener<K extends keyof WindowEventMap>(
  type: K,
  listener: (event: WindowEventMap[K]) => void,
  target?: undefined,
  options?: UseEventListenerOptions,
): void;
function useEventListener<K extends keyof DocumentEventMap>(
  type: K,
  listener: (event: DocumentEventMap[K]) => void,
  target: EventTargetLike<Document>,
  options?: UseEventListenerOptions,
): void;
function useEventListener<K extends keyof HTMLElementEventMap, T extends HTMLElement>(
  type: K,
  listener: (event: HTMLElementEventMap[K]) => void,
  target: EventTargetLike<T>,
  options?: UseEventListenerOptions,
): void;
function useEventListener(
  type: string,
  listener: (event: Event) => void,
  target?: EventTargetLike<EventTarget>,
  options: UseEventListenerOptions = {},
): void {
  const { capture, passive, once, enabled = true } = options;
  const listenerRef = React.useRef(listener);

  React.useEffect(() => {
    listenerRef.current = listener;
  });

  React.useEffect(() => {
    if (!enabled) return undefined;

    const element = resolveEventTarget(target);
    if (!element) return undefined;

    const handler = (event: Event) => listenerRef.current(event);

    element.addEventListener(type, handler, { capture, passive, once });
    return () => element.removeEventListener(type, handler, { capture });
  }, [type, target, capture, passive, once, enabled]);
}

function resolveEventTarget(target: EventTargetLike<EventTarget>): EventTarget | null {
  if (target === undefined) return typeof window === "undefined" ? null : window;
  if (target === null) return null;
  if ("current" in target) return target.current;
  return target;
}

export { useEventListener, type UseEventListenerOptions };
```



## Usage

`addEventListener` in an effect is four lines of boilerplate that go wrong in two ways: the cleanup
gets forgotten, or the handler is added to the dependency array and the listener is torn down and
re-attached on every render. This hook fixes both, and keeps the event fully typed.

```tsx
import { useEventListener } from "@/hooks/use-event-listener";

function ShortcutBar() {
  useEventListener("keydown", (event) => {
    if (event.key === "Escape") close();
  });

  return null;
}
```

With no target, the listener goes on `window` — `event` is narrowed from `WindowEventMap`, so
`event.key` type-checks and `event.clientX` does not.

## Targets

Pass a ref or an element as the third argument. The ref does not need to be populated on the first
render; the listener is attached in an effect.

```tsx
const cardRef = React.useRef<HTMLDivElement>(null);

useEventListener("pointermove", (event) => setPoint({ x: event.clientX, y: event.clientY }), cardRef);
useEventListener("visibilitychange", () => setActive(!document.hidden), document);
```

The event type follows the target: `HTMLElementEventMap` for elements, `DocumentEventMap` for
`document`, `WindowEventMap` for the default.

## Options

| Option    | Description                                                                      |
| --------- | -------------------------------------------------------------------------------- |
| `capture` | Listen during the capture phase.                                                   |
| `passive` | Promise never to call `preventDefault` — required for smooth `touchmove`/`wheel`.  |
| `once`    | Remove the listener after it fires once.                                           |
| `enabled` | Detach without unmounting. Flip it instead of conditionally calling the hook.      |

## Behavior

- The handler is read from a ref, so it always sees fresh props and state without re-subscribing.
  You never need to memoize what you pass in.
- Nothing touches the DOM during render, so the hook is safe in server-rendered trees.
- `target` resolution happens inside the effect, which is why `window` and `document` defaults do
  not break SSR.

