Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,5 +451,134 @@ import {Join} from 'react-extras';
*/
export function Join(props: JoinProps): JSX.Element;

// eslint-disable-next-line @typescript-eslint/ban-types
type NullishTarget<T> = T | null | undefined;
type ReferenceTarget<T> = {readonly current: NullishTarget<T>};

/**
Adds an event listener to an element and automatically removes it on cleanup.

The handler always has access to the latest props/state without needing to specify dependencies.

@param target - The target to add the event listener to.
@param eventName - The name of the event to listen for.
@param handler - The event handler function.
@param options - Options to pass to `addEventListener`.

@example
```
import {useEventListener} from 'react-extras';

function Component() {
useEventListener(document.body, 'click', event => {
console.log('Body clicked!', event);
});

return <div>Click anywhere</div>;
}
```
*/
export function useEventListener<K extends keyof HTMLElementEventMap>(
target: NullishTarget<HTMLElement> | ReferenceTarget<HTMLElement>,
eventName: K,
handler: (event: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;
export function useEventListener<K extends keyof WindowEventMap>(
target: NullishTarget<Window> | ReferenceTarget<Window>,
eventName: K,
handler: (event: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;
export function useEventListener<K extends keyof DocumentEventMap>(
target: NullishTarget<Document> | ReferenceTarget<Document>,
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;
export function useEventListener<K extends keyof MediaQueryListEventMap>(
target: NullishTarget<MediaQueryList> | ReferenceTarget<MediaQueryList>,
eventName: K,
handler: (event: MediaQueryListEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;
export function useEventListener(
target: NullishTarget<EventTarget> | ReferenceTarget<EventTarget>,
eventName: string,
handler: (event: Event) => void,
options?: boolean | AddEventListenerOptions
): void;

/**
Convenience hook for `useEventListener(window, …)` that is SSR-safe.

The handler always has access to the latest props/state without needing to specify dependencies.

@param eventName - The name of the event to listen for.
@param handler - The event handler function.
@param options - Options to pass to `addEventListener`.

@example
```
import {useWindowEvent} from 'react-extras';

function Component() {
useWindowEvent('resize', event => {
console.log('Window resized!', event);
});

return <div>Resize the window</div>;
}
```

@example
```
import {useWindowEvent} from 'react-extras';

function Component() {
useWindowEvent('keydown', event => {
if (event.key === 'Escape') {
console.log('Escape pressed!');
}
});

return <div>Press Escape</div>;
}
```
*/
export function useWindowEvent<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;

/**
Convenience hook for `useEventListener(document, …)` that is SSR-safe.

The handler always has access to the latest props/state without needing to specify dependencies.

@param eventName - The name of the event to listen for.
@param handler - The event handler function.
@param options - Options to pass to `addEventListener`.

@example
```
import {useDocumentEvent} from 'react-extras';

function Component() {
useDocumentEvent('visibilitychange', () => {
console.log('Visibility changed:', document.visibilityState);
});

return <div>Switch tabs to see visibility changes</div>;
}
```
*/
export function useDocumentEvent<K extends keyof DocumentEventMap>(
eventName: K,
handler: (event: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;

export {default as classNames} from '@sindresorhus/class-names';
export {default as autoBind} from 'auto-bind/react';
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,14 @@
"@babel/core": "^7.28.4",
"@babel/preset-react": "^7.27.1",
"@babel/register": "^7.28.3",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.12",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.10",
"ava": "^6.4.1",
"browser-env": "^3.3.0",
"esm": "^3.2.25",
"jest-prop-type-error": "^1.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tsd": "^0.33.0",
"xo": "^0.59.0"
},
Expand Down
50 changes: 50 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,56 @@ import {Join} from 'react-extras';
// => <span>Apple</span>, <span>Orange</span> and <span>Banana</span>
```

### useEventListener(target, eventName, handler, options?)

Adds an event listener to an element and automatically removes it on cleanup.

The handler always has access to the latest props/state without needing to specify dependencies.

```jsx
import {useEventListener} from 'react-extras';

function Component() {
useEventListener(document.body, 'click', event => {
console.log('Body clicked!', event);
});

return <div>Click anywhere</div>;
}
```

### useWindowEvent(eventName, handler, options?)

Convenience hook for `useEventListener(window, …)` that is SSR-safe.

```jsx
import {useWindowEvent} from 'react-extras';

function Component() {
useWindowEvent('resize', event => {
console.log('Window resized!', event);
});

return <div>Resize the window</div>;
}
```

### useDocumentEvent(eventName, handler, options?)

Convenience hook for `useEventListener(document, …)` that is SSR-safe.

```jsx
import {useDocumentEvent} from 'react-extras';

function Component() {
useDocumentEvent('visibilitychange', () => {
console.log('Visibility changed:', document.visibilityState);
});

return <div>Switch tabs to see visibility changes</div>;
}
```

### isStatelessComponent(Component)

Returns a boolean of whether the given `Component` is a [functional stateless component](https://javascriptplayground.com/functional-stateless-components-react/).
Expand Down
1 change: 1 addition & 0 deletions source/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ export {default as Image} from './image.js';
export {default as RootClass} from './root-class.js';
export {default as BodyClass} from './body-class.js';
export {intersperse, Join} from './intersperse.js';
export {useEventListener, useWindowEvent, useDocumentEvent} from './use-event-listener.js';
177 changes: 177 additions & 0 deletions source/use-event-listener.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import {
useEffect,
useLayoutEffect,
useRef,
} from 'react';

const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

const normalizeOptions = options => {
if (options === undefined) {
return {type: 'none'};
}

if (typeof options === 'boolean') {
return {
type: 'boolean',
capture: options,
};
}

return {
type: 'object',
capture: options.capture ?? false,
passive: options.passive,
once: options.once ?? false,
signal: options.signal,
};
};

const createEffectOptions = options => {
if (options.type === 'none') {
return undefined;
}

if (options.type === 'boolean') {
return options.capture;
}

const effectOptions = {capture: options.capture, once: options.once};

if (options.passive !== undefined) {
effectOptions.passive = options.passive;
}

if (options.signal !== undefined) {
effectOptions.signal = options.signal;
}

return effectOptions;
};

const areOptionsEqual = (firstOptions, secondOptions) => {
if (firstOptions?.type !== secondOptions?.type) {
return false;
}

if (firstOptions?.type === 'none') {
return true;
}

if (firstOptions?.type === 'boolean') {
return firstOptions.capture === secondOptions.capture;
}

return firstOptions.capture === secondOptions.capture
&& firstOptions.passive === secondOptions.passive
&& firstOptions.once === secondOptions.once
&& firstOptions.signal === secondOptions.signal;
};

// Support refs by returning their current value, treating null as no target.
const resolveTarget = target => {
if (!target || typeof target !== 'object') {
return target;
}

if (!Object.hasOwn(target, 'current')) {
return target;
}

return target.current;
};

export function useEventListener(target, eventName, handler, options) {
const handlerReference = useRef(handler);
const listenerReference = useRef(event => handlerReference.current(event));
const subscriptionReference = useRef({
target: undefined,
eventName: undefined,
options: undefined,
});

useIsomorphicLayoutEffect(() => {
handlerReference.current = handler;
}, [handler]);

useIsomorphicLayoutEffect(() => {
const nextTarget = resolveTarget(target);
const previousSubscription = subscriptionReference.current;
const normalizedOptions = normalizeOptions(options);
const hasSameTarget = previousSubscription.target === nextTarget;
const hasSameEventName = previousSubscription.eventName === eventName;
const hasSameOptions = areOptionsEqual(previousSubscription.options, normalizedOptions);

// Avoid re-subscribing when target, event, and options are unchanged.
if (hasSameTarget && hasSameEventName && hasSameOptions) {
return;
}

const listener = listenerReference.current;

// Tear down the previous subscription before attaching a new one.
if (previousSubscription.target) {
const previousEffectOptions = createEffectOptions(previousSubscription.options);

if (previousEffectOptions === undefined) {
previousSubscription.target.removeEventListener(previousSubscription.eventName, listener);
} else {
previousSubscription.target.removeEventListener(previousSubscription.eventName, listener, previousEffectOptions);
}
}

// Subscribe when a target exists, otherwise treat as no-op.
if (nextTarget) {
const nextEffectOptions = createEffectOptions(normalizedOptions);

if (nextEffectOptions === undefined) {
nextTarget.addEventListener(eventName, listener);
} else {
nextTarget.addEventListener(eventName, listener, nextEffectOptions);
}
}

subscriptionReference.current = {
target: nextTarget,
eventName,
options: normalizedOptions,
};
});

// Cleanup in layout effect so unmounts before passive effects still detach.
useIsomorphicLayoutEffect(() => () => {
const currentSubscription = subscriptionReference.current;

if (!currentSubscription.target) {
subscriptionReference.current = {
target: undefined,
eventName: undefined,
options: undefined,
};
return;
}

const listener = listenerReference.current;
const effectOptions = createEffectOptions(currentSubscription.options);

if (effectOptions === undefined) {
currentSubscription.target.removeEventListener(currentSubscription.eventName, listener);
} else {
currentSubscription.target.removeEventListener(currentSubscription.eventName, listener, effectOptions);
}

subscriptionReference.current = {
target: undefined,
eventName: undefined,
options: undefined,
};
}, []);
}

export function useWindowEvent(eventName, handler, options) {
useEventListener(typeof window === 'undefined' ? undefined : window, eventName, handler, options);
}

export function useDocumentEvent(eventName, handler, options) {
useEventListener(typeof document === 'undefined' ? undefined : document, eventName, handler, options);
}
Loading