React uses synthetic events — cross-browser wrappers around native browser events that provide a consistent API across all browsers. Event handler names in JSX are written in camelCase (onClick, onChange, onSubmit) and receive a function reference rather than a string like in HTML. React attaches a single delegated listener at the root rather than on each element, making the event system efficient even in large trees. The handler receives a SyntheticEvent object with properties like target, preventDefault(), and stopPropagation().
function Button() {
const handleClick = (e) => {
e.preventDefault();
console.log('Clicked!');
};
return <button onClick={handleClick}>Click Me</button>;
}
Why it matters: React uses camelCase event names and passes functions, not strings. This keeps event logic in your JS code instead of inline HTML attributes.
Real applications: onClick for buttons, onSubmit for forms, onChange for inputs — every interactive UI element uses event handlers.
Common mistakes: Calling the function immediately like onClick={handleClick()} instead of passing a reference onClick={handleClick}.
SyntheticEvent is React's cross-browser wrapper that conforms to the W3C event specification. It exposes the same interface as native DOM events — stopPropagation(), preventDefault(), target, currentTarget — but guarantees identical behavior across all browsers with no browser-specific compatibility code needed. React handles all browser quirks internally. In older React versions events were pooled for performance (the object was reused after handlers ran), but React 17+ removed pooling since modern JS engines handle object allocation efficiently.
Why it matters: Synthetic events ensure your code works the same in all browsers without writing browser-specific workarounds.
Real applications: Handling keyboard shortcuts, reading mouse positions, canceling form submissions — all work consistently in every browser.
Common mistakes: Accessing the event object asynchronously after it has been pooled. Store the values you need before any async code runs.
By default, a React event handler receives only the SyntheticEvent object as its argument. To pass extra data like an item ID, the two main approaches are wrapping the handler in an inline arrow function (onClick={() => handleDelete(item.id)}), or storing the data in a data-* attribute and reading it from e.currentTarget.dataset inside the handler. The arrow function is simple but creates a new function reference on every render, which can trigger unnecessary re-renders in memoized children. The data-* approach avoids that but only works with serializable values like strings and numbers.
<button onClick={() => handleDelete(item.id)}>Delete</button>
// Using data attributes
<button data-id={item.id} onClick={handleDelete}>Delete</button>
const handleDelete = (e) => {
const id = e.currentTarget.dataset.id;
};
Why it matters: Event handlers receive the event object by default. Wrapping in an arrow function lets you pass extra data like an item ID.
Real applications: Deleting a specific item from a list, toggling a specific row in a table, changing a specific field in a form.
Common mistakes: Writing onClick={handleDelete(id)} — this calls the function immediately on render instead of waiting for a click.
React uses event delegation by default — instead of attaching event listeners to each element, React attaches a single listener at the root container. When a native event bubbles up to the root, React uses its internal mapping to match it to the right component handler. This is far more memory-efficient in large lists where every item has click handlers. One nuance: because all React handlers share one root listener, e.stopPropagation() stops events reaching other React listeners in the tree, but you need e.nativeEvent.stopImmediatePropagation() when mixing React and non-React listeners on the same container.
Why it matters: Attaching one listener to the root instead of thousands of individual nodes is faster and uses much less memory.
Real applications: Any React app — large lists, tables, or dynamic content — all benefit from this automatically without any extra work.
Common mistakes: Mixing React's delegated events with native DOM listeners on the same element — this can cause confusing double-firing.
Call e.preventDefault() on the SyntheticEvent to block the browser's default behavior for an event. The most common use cases are preventing form submissions from reloading the page, preventing link clicks from navigating, and blocking the default drag behavior in custom drag-and-drop. Unlike older code that used return false or e.returnValue = false, React's e.preventDefault() is the only valid approach in JSX handlers. It must be called synchronously inside the handler — it cannot be called in a setTimeout or Promise callback after the handler returns.
function Form() {
const handleSubmit = (e) => {
e.preventDefault();
// Handle form data
};
return <form onSubmit={handleSubmit}>...</form>;
}
Why it matters: Some browser behaviors (like a page reload on form submit or link navigation) need to be stopped so React can handle them instead.
Real applications: Preventing form submission from reloading the page, stopping a link from navigating away, blocking drag-and-drop browser defaults.
Common mistakes: Returning false from the handler (like you might in jQuery) — in React you must explicitly call e.preventDefault().
Keyboard events in React are handled with onKeyDown, onKeyUp, and the deprecated onKeyPress. The event's e.key property gives a human-readable string ('Enter', 'Escape', 'ArrowUp', 'Tab') that is consistent across browsers. Check e.ctrlKey, e.shiftKey, e.altKey, and e.metaKey for modifier keys. For inputs that need rich keyboard navigation — like autocomplete dropdowns or custom menus — combining onKeyDown with aria-* attributes is essential for accessibility compliance.
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
performSearch();
}
if (e.key === 'Escape') {
clearInput();
}
};
return <input onKeyDown={handleKeyDown} />;
}
Why it matters: Keyboard accessibility is critical for users who don't use a mouse. Keyboard events let you support navigation and custom shortcuts.
Real applications: Submitting a search when the user presses Enter, closing a modal on Escape, navigating a list with arrow keys.
Common mistakes: Using e.keyCode (deprecated) instead of e.key. Also forgetting to manage focus so the keyboard events actually fire.
In React, onChange fires on every keystroke as the user types — it mirrors the native input event rather than the native change event (which only fires on blur). This makes it the standard way to build controlled inputs, where the component's state mirrors the input value in real time. Pairing a controlled value prop with an onChange handler gives you a single source of truth in state, not in the DOM. For uncontrolled inputs where you don't need to track every keystroke, onBlur combined with a ref is a lightweight alternative.
Why it matters: In React, onChange fires immediately on every character change, making it the right choice for controlled inputs.
Real applications: Any controlled form input — text fields, checkboxes, selects, textareas — all use onChange.
Common mistakes: Expecting React's onChange to behave like native HTML's onchange (which only fires on blur) — it doesn't, it fires on every keystroke.
When rendering a large list, attaching a separate event handler to each item is expensive. A better approach is to attach one handler to the parent container and let native event bubbling carry events up from any child. Inside the handler, read e.target or e.currentTarget to identify which item was acted on — typically via a data-* attribute. This parent-level delegation uses a single function reference for any number of items, reducing memory usage and keeping memoization effective for the parent component.
function List({ items, onDelete }) {
const handleClick = (e) => {
const id = e.target.closest('[data-id]')?.dataset.id;
if (id) onDelete(id);
};
return (
<ul onClick={handleClick}>
{items.map(item => (
<li key={item.id} data-id={item.id}>{item.name} <button>X</button></li>
))}
</ul>
);
}
Why it matters: Using data attributes avoids creating new function instances for every list item, which keeps memory usage low in large lists.
Real applications: Delete buttons in a to-do list, select buttons in a product catalog, expand/collapse rows in a large table.
Common mistakes: Creating an inline arrow function for each item like onClick={() => handleDelete(id)} in large lists — this creates a new function on every render.
React's synthetic event system only covers events that bubble to the root container. For window-level or document-level events like scroll, resize, or keydown, you must use addEventListener directly inside a useEffect. Always remove the listener in the cleanup function to prevent handlers from lingering after unmount, which causes memory leaks and stale-closure bugs. Listing relevant state in the dependency array ensures the listener is re-registered with a fresh closure whenever those values change.
}, []);
Why it matters: Some events (scroll, resize, keydown globally) exist outside the React component tree and must be added to the window directly.
Real applications: Detecting scroll position for sticky headers, tracking window resize to update layout, listening for global keyboard shortcuts.
Common mistakes: Not removing the event listener in the cleanup function — the handler keeps running after the component unmounts, causing memory leaks.
e.stopPropagation() prevents an event from bubbling up the component tree so parent handlers are not triggered when a child handles it. This is the standard solution for nested interactive elements — like a delete button inside a clickable card row where the button should not also fire the row's click handler. Use it deliberately and sparingly: overusing it makes event flow hard to reason about. To stop the event from reaching non-React listeners on the same element, use e.nativeEvent.stopImmediatePropagation() instead.
Why it matters: Without stopping propagation, clicking a nested element can trigger all of its parents' click handlers — causing unintended actions.
Real applications: Clicking a delete button inside a card that also has a click handler; closing a dropdown only when clicking outside it.
Common mistakes: Calling stopPropagation on every event as a defensive habit — it can block other legitimate listeners from running.
React exposes the full HTML5 Drag and Drop API through synthetic events like onDragStart, onDragOver, onDrop, and onDragEnd. The draggable element fires onDragStart where you store item data with e.dataTransfer.setData(), and the drop target reads it in onDrop with e.dataTransfer.getData(). You must call e.preventDefault() in onDragOver to signal the browser that dropping is allowed — without it, the onDrop event won't fire. For complex sortable UIs, libraries like dnd-kit or react-beautiful-dnd handle edge cases and accessibility.
function DraggableItem({ id, label }) {
const handleDragStart = (e) => {
e.dataTransfer.setData('text/plain', id);
e.dataTransfer.effectAllowed = 'move';
};
return (
<div draggable onDragStart={handleDragStart}>{label}</div>
);
}
function DropZone({ onDrop }) {
const handleDrop = (e) => {
e.preventDefault();
const id = e.dataTransfer.getData('text/plain');
onDrop(id);
};
return (
<div
onDragOver={(e) => e.preventDefault()} // required to allow drop
onDrop={handleDrop}
className="drop-zone"
>
Drop here
</div>
);
}onDragOver must call e.preventDefault() to allow the drop. For complex DnD use cases, libraries like dnd-kit or react-beautiful-dnd are much easier to work with.
Why it matters: Drag-and-drop is a common UI pattern. React's built-in drag events work but get complex fast, so libraries are often the better choice.
Real applications: Reordering a kanban board, dragging files into an upload area, rearranging items in a list.
Common mistakes: Forgetting to call e.preventDefault() on onDragOver — without it, the browser won't allow the drop to happen.
onMouseEnter fires only when the pointer crosses the element boundary itself — it does not bubble through the event tree. onMouseOver fires when the pointer enters the element or any of its descendants and does bubble, so it fires repeatedly as the cursor moves through nested children. For hover effects on a container, prefer onMouseEnter / onMouseLeave to prevent repeated firings. Use onMouseOver only when you specifically need to know which nested child element is being hovered.
// onMouseEnter — fires once when entering the container
<div onMouseEnter={() => setHovered(true)}>
<span>Child</span> {/* moving here doesn't re-fire */}
</div>
// onMouseOver — fires again when entering each child
<div onMouseOver={() => console.log('over')}>
<span>Child</span> {/* moving here fires again */}
</div>Use onMouseEnter/onMouseLeave for hover effects on a container. Use onMouseOver when you need to detect hovering over specific children.
Why it matters: Picking the wrong event causes your hover handler to fire too many times or not at all, making interactions feel buggy.
Real applications: Hover effects on cards or menu items (onMouseEnter), detecting which exact child element the mouse is on (onMouseOver).
Common mistakes: Using onMouseOver for a container hover when onMouseEnter is needed — it fires repeatedly as you move over child elements inside.
React's one-way data flow means siblings cannot directly call each other's functions. For cross-component communication without a direct parent, the two main options are a Context-based event emitter (expose a function via context that triggers a state update) or a shared pub-sub module (any component can subscribe to or emit named events). For most cases, lifting state up to the nearest common ancestor or using a state library like Zustand is simpler and easier to debug. Reserve the event emitter pattern for genuinely decoupled subsystems where a shared parent is impractical.
// Simple pub-sub with Context
const EventContext = createContext(null);
function EventProvider({ children }) {
const listeners = useRef({});
const emit = (event, data) =>
listeners.current[event]?.forEach(fn => fn(data));
const on = (event, fn) => {
listeners.current[event] = [...(listeners.current[event] || []), fn];
return () => {
listeners.current[event] = listeners.current[event].filter(f => f !== fn);
};
};
return <EventContext.Provider value={{ emit, on }}>{children}</EventContext.Provider>;
}
// Publisher
const { emit } = useContext(EventContext);
emit('userLoggedIn', { userId: 123 });
// Subscriber
const { on } = useContext(EventContext);
useEffect(() => on('userLoggedIn', handleLogin), []);For most cases, lifting state up or using Context is simpler. Custom events are useful for loosely coupled cross-component communication.
Why it matters: Sometimes two components are far apart in the tree and lifting state or using Context would add too much boilerplate.
Real applications: Notifying a notification bar when a login succeeds, triggering a data refresh in a sibling panel.
Common mistakes: Overusing custom events when lifting state up or using Context would be simpler and more predictable to debug.
Some DOM events like paste, copy, visibilitychange, online/offline, and custom events via CustomEvent are not part of React's synthetic event system. For these, use native addEventListener inside a useEffect so it runs after mount. Always return a cleanup function that calls removeEventListener to prevent handler leaks after unmount. Alternatively, pass an options object with { signal: abortController.signal } to the native listener for a modern cleanup approach without explicit removal.
useEffect(() => {
// visibilitychange is not a React synthetic event
const handleVisibility = () => {
if (document.hidden) pauseVideo();
else resumeVideo();
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
// Custom DOM events
useEffect(() => {
const handler = (e) => console.log(e.detail);
window.addEventListener('myCustomEvent', handler);
return () => window.removeEventListener('myCustomEvent', handler);
}, []);Always return a cleanup function to remove the listener when the component unmounts, preventing memory leaks.
Why it matters: Some browser events like visibilitychange or beforeunload are not in React's event system and must be added manually with addEventListener.
Real applications: Detecting when the tab becomes hidden or visible, listening for clipboard paste events, showing a "leave page?" warning.
Common mistakes: Not removing the listener in the cleanup function — the handler keeps firing even after the component unmounts.