Web TippsUse custom web fonts in Google Sheets charts(08.09.2026 um 17:05 Uhr)
Web TippsIntroducing the new 1Password App for Google Chat(08.09.2026 um 18:02 Uhr)
Web TippsUse custom web fonts in Google Sheets charts(08.09.2026 um 17:05 Uhr)
Web TippsIntroducing the new 1Password App for Google Chat(08.09.2026 um 18:02 Uhr)

🔧 Programmierung 🕛 vor 3 Monaten 16 Min Lesezeit
0

Taming DOM Events in React: useEventListener, useEventEmitter, useKeyModifier, useTextSelection, useDebounceFn, useThrottleFn

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht




Taming DOM Events in React: useEventListener, useEventEmitter, useKeyModifier, useTextSelection, useDebounceFn, useThrottleFn



The DOM event model and the React render model do not get along. addEventListener wants a stable function reference; React hands you a new closure on every render. setTimeout-backed debounces want to outlive a frame; React reaches in and unmounts the component while the timer is still running. The keyboard tells you a key went down with one event and back up with another, but if the user alt-tabs in between, the up event never arrives and your "Shift is held" flag is stuck on true forever. The Selection API does not even fire selectionchange reliably on the same Selection object — it mutates the existing one and expects you to notice.



Every codebase ends up with the same patches for these. A useEffect that adds and removes a listener. A lodash debounce inside a ref. A keydown/keyup reducer with an Alt+Tab workaround that nobody quite remembers writing. The patches work. They are also five lines of intent buried under twenty lines of cleanup, and the cleanup is exactly where the bugs live.



, the pattern will be familiar — every hook in this list closes over its callback through bundles all three lines into the hook and leaves the component looking like the version you would draw on a whiteboard.






1. useEventListener — addEventListener, Without the Leak



internally, so onCmdK is read fresh on every event without re-binding the listener. Pass a brand-new arrow function on every render and the actual DOM listener still binds once, on mount.



A ref-targeted variant looks the same:




CODE
function VideoControls({ videoRef }: { videoRef: React.RefObject<HTMLVideoElement> }) {
const [time, setTime] = useState(0);

useEventListener('timeupdate', () => {
if (videoRef.current) setTime(videoRef.current.currentTime);
}, videoRef);

return <div>{time.toFixed(1)}s</div>;
}






Two implementation details are worth knowing. The hook accepts the target as a ref, a node, or a function that returns one — the gives you a typed pub-sub primitive scoped to whatever component creates it:




CODE
import { useEventEmitter } from '@reactuses/core';

type ToastEvent = { kind: 'success' | 'error'; message: string };

function App() {
const [event, fire] = useEventEmitter<ToastEvent>();

return (
<ToastContext.Provider value={{ event, fire }}>
<Form />
<ToastViewport />
</ToastContext.Provider>
);
}









CODE
function Form() {
const { fire } = useContext(ToastContext);
return (
<button onClick={() => fire({ kind: 'success', message: 'Saved' })}>
Save
</button>
);
}

function ToastViewport() {
const { event } = useContext(ToastContext);
const [toasts, setToasts] = useState<ToastEvent[]>([]);

useEffect(() => {
const sub = event((toast) => {
setToasts((ts) => [...ts, toast]);
setTimeout(() => setToasts((ts) => ts.slice(1)), 3000);
});
return () => sub.dispose();
}, [event]);

return <div className="toasts">{toasts.map((t, i) => <Toast key={i} {...t} />)}</div>;
}






Three things to notice. The hook returns a tuple — [event, fire, dispose] — and event is the subscribe function, not a data field. Calling event(listener) returns a { dispose } handle, the same shape as vscode.Disposable. The fire function takes one or two positional arguments and broadcasts to every listener synchronously; the broadcast is a copy-on-iterate loop, so a listener that unsubscribes itself during the call does not skip neighbors. And dispose() removes all listeners at once — useful when the emitter lives on a context that itself is about to unmount.



The pattern beats context-with-state when the receiver does not need to re-render unless an event arrives. A pure useEffect(() => event(listener), [event]) subscription means the toast viewport renders only when a toast comes in, not on every keystroke in the form. If you have ever profiled a flame graph and found a top-level context provider rerendering everything in the app, this is the hook you replace it with for the "fire-and-forget notification" cases.



There is a subtle quirk: the emitter is created with useRef, so it is stable across renders of the component that owns it — you can put it in a dependency array safely. But it is not shared between sibling components unless you put it on a context or pass it as a prop. Sharing across the whole app is a one-time useEventEmitter at the root plus a context provider; sharing within a subtree is whatever scope you choose.






3. useKeyModifier — Modifier State That Stays in Sync



The naive way to track whether Shift is currently held:




CODE
const [shift, setShift] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => { if (e.key === 'Shift') setShift(true); };
const up = (e: KeyboardEvent) => { if (e.key === 'Shift') setShift(false); };
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
}, []);






This works in the demo and breaks in three places. The user holds Shift, alt-tabs to another window, releases Shift outside the page — the keyup never fires and your flag is stuck on true. The user holds Shift, then clicks something — the click handler runs with stale Shift state because keydown updates the state asynchronously. And on macOS, the OS sometimes swallows the keyup after a Command+Shift+key shortcut, leaving both Cmd and Shift "held" until the next keypress.



handles both halves of that:




CODE
import { useTextSelection } from '@reactuses/core';

function HighlightToolbar() {
const selection = useTextSelection();
const text = selection?.toString() ?? '';

if (!text) return null;

const range = selection!.getRangeAt(0);
const rect = range.getBoundingClientRect();

return (
<div
className="toolbar"
style={{
position: 'fixed',
top: rect.top - 40,
left: rect.left + rect.width / 2,
transform: 'translateX(-50%)',
}}
>
<button onClick={() => navigator.clipboard.writeText(text)}>Copy</button>
<button onClick={() => share(text)}>Share</button>
</div>
);
}






The hook does two things to make this work. First, it listens to selectionchange on the document via useEventListener, so the cleanup is handled. Second, it pairs setState with a useUpdate() force-render — because document.getSelection() returns the same object every time, the useState setter shortcuts out and the toolbar does not re-render to the new range. The force-update is the workaround for an API older than React itself; the hook hides it so your component reads as if Selection were a normal immutable value.



Two practical notes. The hook does not give you the rendered range — you have to call selection.getRangeAt(0).getBoundingClientRect() yourself if you want pixel coordinates, which is what the example does. And the Selection API works on contenteditable elements and ordinary prose alike; if you are building a highlighter for a long-form reader (Medium-style), this is the primitive. If you are building a rich-text editor with structured ranges, you probably want a higher-level library like ProseMirror or Lexical — useTextSelection is a window onto the platform, not a replacement for editor state.






5. useDebounceFn — Function-Level Debounce That Cleans Up on Unmount



takes the same shape as useDebounceFn(fn, wait?, options?) returning { run, cancel, flush } — and the same internal hygiene: stable identity, latest-ref callback, cancel on unmount. The behavioral difference is in lodash.throttle: by default both leading and trailing edges fire, so the first scroll event runs immediately (no perceptible lag) and the last one runs at the end of the throttle window (no missed final position).



Use throttle for continuous streams where you want regular sampling — scroll position, mouse coordinates, resize handlers driving expensive layout reads. Use debounce for "tell me when the user has paused" — search input, autosave, validation. A common bug is reaching for debounce on a scroll listener; the user keeps scrolling, the trailing edge never fires until they stop, and your scroll-linked progress bar sits at zero until they let go.



A nuance about combining useEventListener and useThrottleFn: the example above passes run directly as the event handler, and that is correct because run is the throttled function. Be careful not to pass the inner callback by mistake — the throttle only applies if you call the wrapper.






Putting It Together: A Keyboard-Aware Selection Toolbar



A small component that uses four of these hooks at once. A floating toolbar appears over any text the user selects, the copy button skips the clipboard prompt when the user holds Shift (to copy as plain text), the position updates at most every 16 ms on scroll, and a global emitter broadcasts the copied text to anyone listening:




CODE
import { useState, useContext } from 'react';
import {
useTextSelection,
useKeyModifier,
useEventListener,
useThrottleFn,
useEventEmitter,
} from '@reactuses/core';

type CopyEvent = { text: string; plain: boolean };
const CopyContext = React.createContext<ReturnType<typeof useEventEmitter<CopyEvent>> | null>(null);

function SelectionRoot({ children }: { children: React.ReactNode }) {
const emitter = useEventEmitter<CopyEvent>();
return <CopyContext.Provider value={emitter}>{children}{<SelectionToolbar />}</CopyContext.Provider>;
}

function SelectionToolbar() {
const selection = useTextSelection();
const shift = useKeyModifier('Shift');
const ctx = useContext(CopyContext);
const [rect, setRect] = useState<DOMRect | null>(null);

const { run: updateRect } = useThrottleFn(() => {
if (selection && selection.toString()) {
setRect(selection.getRangeAt(0).getBoundingClientRect());
} else {
setRect(null);
}
}, 16);

useEventListener('scroll', updateRect, () => window, { passive: true });

React.useEffect(updateRect, [selection]);

const text = selection?.toString() ?? '';
if (!text || !rect || !ctx) return null;
const [, fire] = ctx;

return (
<div
className="floating-toolbar"
style={{
position: 'fixed',
top: rect.top - 40,
left: rect.left + rect.width / 2,
transform: 'translateX(-50%)',
}}
>
<button
onClick={async () => {
if (shift) {
await navigator.clipboard.writeText(text);
} else {
await navigator.clipboard.write([
new ClipboardItem({ 'text/html': new Blob([text], { type: 'text/html' }) }),
]);
}
fire({ text, plain: shift });
}}
>
Copy {shift ? '(plain)' : ''}
</button>
</div>
);
}






Five hooks, each line of caller code corresponding to one specific behavior. The equivalent component without them is roughly 80 lines once you have written the scroll listener cleanup, the selectionchange same-object workaround, the shift-key keydown/keyup reducer, the throttle, and the cross-component notification. That ratio — twenty lines of intent vs eighty lines of plumbing — is the case for picking up the library instead of repeating the workaround in every codebase.






When to Reach for Which
























You want to Use
Attach a DOM listener with automatic cleanup
Know whether Shift / Ctrl / Alt / Meta is held
Wait for the user to pause before running a function


Two non-rules. If you want a value that debounces — for example a query string that lags the input by 300 ms — reach for useDebounce (state version) rather than useDebounceFn (function version). Same for throttle. The Fn variants are for callbacks; the bare names are for state values. And if you find yourself reaching for useEventEmitter to broadcast something that already lives in state, you probably want context with a useReducer instead — the emitter is for transient signals, not state synchronization.






Installation






CODE
npm install @reactuses/core
# or
pnpm add @reactuses/core
# or
yarn add @reactuses/core






All six hooks tree-shake individually — importing useEventListener does not pull in useTextSelection. Each ships TypeScript types and works in both client-rendered apps and SSR frameworks (Next.js, Remix, Astro); the listeners that need a DOM no-op on the server, and the hooks return safe defaults until hydration.






Related Hooks



If event handling is your bottleneck, two adjacent ReactUse posts are worth a read. and covers useHover, useLongPress, useDoubleClick, and useClickOutside, which all share the same "ref-targeted listener with latest-ref callback" pattern in their internals.



Browse the full set at reactuse.com, or open one of the hooks above and read the source — most are under 50 lines, and you will probably find one or two you have been re-implementing in your own codebase for years.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
3 Quellen
Use custom web fonts in Google Sheets charts
2 Quellen
Introducing the new 1Password App for Google Chat
1 Quelle
Context-aware access controls are available for Gemini Enterprise in the Admin console
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Taming DOM Events in React: useEventListener, useEventEmitter, useKeyModifier, useTextSelection, useDebounceFn, useThrottleFn

Thematisch verwandte Begriffe: Taming, Events, React, useEventListener · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...