Event listeners in React: the reliable pattern
A practical guide to subscribing to browser events in React without stale values, duplicate listeners, or missing cleanup.
- React
- JavaScript
- Frontend
React already gives us event props such as onClick and onKeyDown. Use those first when the event belongs to an element you render: the code stays declarative, follows React's lifecycle, and works naturally with accessibility tools.
Sometimes the event lives outside that tree. Keyboard shortcuts on window, viewport changes, media queries, and third-party APIs all require a subscription. The reliable pattern is to treat that subscription as synchronization with an external system.
The basic pattern
Subscribe in an effect and return the matching cleanup:
import { useEffect } from "react";
export const EscapeListener = ({ onEscape }: { onEscape: () => void }) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onEscape();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onEscape]);
return null;
};The effect runs after the component commits. When onEscape changes, React removes the old listener before adding the new one. When the component unmounts, it removes the final listener.
The cleanup matters. Without it, remounting a component can leave old listeners behind, so one user action triggers several handlers.
Handler identity is not the problem it appears to be
removeEventListener must receive the same function object used by addEventListener. In the example above, it does: the cleanup closes over the handleKeyDown created for that exact effect run.
You do not need useCallback merely to make cleanup work. Use it when you need a stable function for another reason, such as when a memoized child receives the callback.
What does cause trouble is removing a different inline function:
// This does not remove the original listener.
window.addEventListener("resize", () => measure());
window.removeEventListener("resize", () => measure());Those two arrow functions look alike, but they are different objects.
Keep reactive values honest
An event handler can read props and state. Those values belong in the effect's dependency list so the subscription stays synchronized:
useEffect(() => {
const handleOnline = () => {
trackConnection(userId, "online");
};
window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline);
}, [userId]);Do not silence the dependency lint rule to freeze an old value. A stale closure is harder to debug than an intentional re-subscription.
For a high-frequency event, re-subscribing is usually still cheap. If profiling shows it is a real problem, move non-reactive logic into an Effect Event when your React version supports it, or store only the latest callback in a ref. That is an optimization, not the default pattern.
Use a ref for the target element
When you must call a browser API on a DOM element, a ref identifies that element:
import { useEffect, useRef } from "react";
export const ScrollPanel = () => {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
const handleScroll = () => {
console.log(panel.scrollTop);
};
panel.addEventListener("scroll", handleScroll, { passive: true });
return () => panel.removeEventListener("scroll", handleScroll);
}, []);
return <div ref={panelRef}>…</div>;
};Capture the current element in a local variable. A ref may point somewhere else by the time cleanup runs, while panel still refers to the node that received the listener.
If React supports the event as a prop, the simpler version is usually better:
export const ScrollPanel = () => {
return <div onScroll={(event) => console.log(event.currentTarget.scrollTop)}>…</div>;
};Listener options must also match
The capture flag is part of a listener's identity. If you subscribe with capture: true, remove it with the same setting:
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
// Handle an outside interaction.
};
document.addEventListener("pointerdown", handlePointerDown, { capture: true });
return () =>
document.removeEventListener("pointerdown", handlePointerDown, {
capture: true,
});
}, []);Options such as passive and once change behavior, but capture is the option that must match for removal.
Strict Mode is a useful test
In development, React Strict Mode may run an extra setup-and-cleanup cycle. That is deliberate: it exposes effects that do not undo their work.
If a subscription behaves correctly after setup → cleanup → setup, it is likely to survive navigation, remounting, and future UI changes. Fix the cleanup instead of adding a flag that prevents the second setup.
A short checklist
- Prefer React event props for elements you render.
- Subscribe to external event sources inside an effect.
- Return cleanup from the same effect.
- Include every reactive value the handler reads.
- Use the same event type, function, and capture setting for removal.
- Reach for refs to identify DOM nodes, not to hide ordinary data flow.
- Treat Strict Mode's extra cycle as a test of symmetry.
The key idea is simple: an event subscription is an external system. Describe how to connect to it, describe how to disconnect from it, and let React keep the two in sync.
Join the conversation
Written by Precious Origho
Keep reading
Related articles
Building a blog with React and RestDB
A practical guide to using RestDB as a managed backend for a React-powered publishing workflow.
- React
- NoSQL
- Database
Using Recoil instead of Redux for state management
An introduction to Recoil and a comparison with Redux for shared state in React applications.
- React
- State management
- JavaScript