How to Handle Events in React
Advertisement
🧩 Scenario
Architecture Walkthrough
Synthetic Events and Event Delegation
React does not attach event listeners to individual DOM nodes. It uses event delegation: a single listener at the root captures all events via bubbling. React intercepts the native event and wraps it in a SyntheticEvent object that normalizes the event interface across browsers. The SyntheticEvent has the same API as the native event (type, target, preventDefault, stopPropagation, etc.) but is consistent across environments.
In React 17, the root changed from document to the React root DOM node, which allows multiple React versions on the same page to have independent event systems.
Passing References vs Calling Functions
The most common beginner mistake: onClick={handleClick()} calls handleClick immediately during render and assigns its return value (usually undefined) as the handler. The button becomes unresponsive. onClick={handleClick} passes the function reference; React calls it when the click occurs.
For handlers that need extra data (like an item ID in a list), wrap with an arrow function: onClick={() => handleDelete(item.id)}. This creates a new function reference on each render. For memoized children, wrap the base handler in useCallback and use a data attribute or closure to pass the ID.
preventDefault and stopPropagation
e.preventDefault() prevents the browser's default action for the event: form submission page reload, link navigation, context menu on right-click. It does not affect event propagation.
e.stopPropagation() prevents the event from bubbling further up the DOM tree. Parent elements with the same event type will not receive it. It does not affect the browser's default action.
Key Code Explained
// Basic event handling: pass reference, not call
function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // prevent page reload on form submit
if (query.trim()) {
onSearch(query.trim());
}
};
return (
<form onSubmit={handleSubmit}> {/* pass reference, not handleSubmit() */}
<input
value={query}
onChange={(e) => setQuery(e.target.value)} // arrow function to extract value
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
// Passing extra data to handlers in a list
interface Item {
id: string;
name: string;
}
function ItemList({ items, onDelete }: { items: Item[]; onDelete: (id: string) => void }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}
{/* Option 1: arrow function wrapper — new reference each render */}
<button onClick={() => onDelete(item.id)}>Delete</button>
{/* Option 2: data attribute — avoids new reference, handler reads from dataset */}
<button data-id={item.id} onClick={handleDeleteByDataset}>
Delete (data attr)
</button>
</li>
))}
</ul>
);
}
function handleDeleteByDataset(e: React.MouseEvent<HTMLButtonElement>) {
const id = e.currentTarget.dataset.id; // read from data attribute
if (id) deleteItem(id);
}
// stopPropagation vs preventDefault
function ClickableCard({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<div onClick={onClick} className="card">
{children}
<a
href="/details"
onClick={(e) => {
e.stopPropagation(); // prevents card's onClick from firing
e.preventDefault(); // prevents navigation to /details
openModal();
}}
>
View Details
</a>
</div>
);
}
// useCallback for stable handler in memoized child
const MemoizedDeleteButton = React.memo(({ itemId, onDelete }: { itemId: string; onDelete: (id: string) => void }) => {
return <button onClick={() => onDelete(itemId)}>Delete</button>;
});
function ParentList({ items }: { items: Item[] }) {
const [list, setList] = useState(items);
// Stable reference: useCallback prevents MemoizedDeleteButton from re-rendering
const handleDelete = useCallback((id: string) => {
setList((prev) => prev.filter((item) => item.id !== id));
}, []);
return (
<ul>
{list.map((item) => (
<li key={item.id}>
{item.name}
<MemoizedDeleteButton itemId={item.id} onDelete={handleDelete} />
</li>
))}
</ul>
);
}
The e.currentTarget in handleDeleteByDataset is the element the handler is attached to (the button), while e.target is the element that was actually clicked. If the button contains a child span or icon, e.target could be the icon, but e.currentTarget is always the button. Use currentTarget when reading data attributes to avoid bugs from click targets being child elements.
Tradeoffs
| Handler pattern | Creates new ref each render | Works with React.memo | Use when |
|---|---|---|---|
| Inline arrow function | Yes | No (breaks memo) | Simple cases, non-memoized children |
| Named function reference | No | Yes | Top-level handlers with no extra args |
| useCallback | No (after first render) | Yes | Handlers passed to memoized children |
| data attribute | No | Yes | Event delegation in a list |
What Interviewers Actually Check
- Whether you know to pass a reference (
onClick={fn}) not a call (onClick={fn()}) - Whether you call
e.preventDefault()on form submit and link click handlers - Whether you know the difference between
preventDefaultandstopPropagation - Whether you know
e.targetvse.currentTarget - Whether you know about React's synthetic event and event delegation model
Follow-Up Questions
- How does React's event delegation change when multiple React roots are on the same page (as allowed in React 17+)?
- What is passive event listeners and how does React's synthetic event system interact with them for
scrollandtouchstart? - How would you implement a global keyboard shortcut handler using
useEffectandaddEventListenerinstead of JSX event props? - How does React handle events that do not bubble (like
focusandblur) and what are theonFocus/onBlurequivalents that do bubble? - How would you use
useTransitionto mark a state update triggered by an event as low-priority to keep the input responsive?
Common Candidate Mistakes
- Writing
onClick={handleClick()}which calls the function during render instead of passing it as a handler - Forgetting
e.preventDefault()on form submit, causing the page to reload and state to be lost - Using
stopPropagationwhenpreventDefaultis needed (or vice versa), not understanding the difference - Assuming
e.targetis always the element the handler is on when it can be a child element - Not knowing that React event delegation in React 17+ attaches to the React root, not
document
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you attach
onClick,onChange, andonSubmithandlers correctly in JSX? - Can you explain the difference between
onClick={handleClick}andonClick={handleClick()}? - Can you call
e.preventDefault()to prevent default browser behavior? - Can you pass extra data to a handler in a list using an arrow function wrapper or data attributes?
- Can you explain what React event delegation is and how it differs from attaching native listeners?
Summary
React handles events through a synthetic event system that wraps native browser events. Instead of attaching individual listeners to DOM nodes, React uses event delegation: one listener at the React root captures all events. The SyntheticEvent object provides a consistent interface across browsers.
Event handlers in JSX use camelCase names and receive a function reference, not a function call. onClick={handleClick} is correct; onClick={handleClick()} calls the function during render. For handlers that need extra context (like an item ID), wrap in an arrow function: onClick={() => handleDelete(item.id)}. For memoized children, use useCallback to stabilize the handler reference.
e.preventDefault() prevents the browser's default behavior for the event (form submit page reload, link navigation) without affecting event bubbling. e.stopPropagation() prevents the event from bubbling to parent elements without affecting the browser's default behavior. They are independent and can be used together.
Is React SyntheticEvent the same as a native DOM event?
No. SyntheticEvent is a cross-browser wrapper that normalizes the event interface. In React 17+, it is no longer pooled, so properties like e.target.value remain accessible in async callbacks without calling e.persist().
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement