Event Handling in React
Event Handling in React
LinkedIn Hook
You click a button in React and it works. So you assume React events are the same as DOM events.
They are not. Not even close.
React wraps every browser event in a SyntheticEvent. Before React 17, it pooled those events and nullified them after your handler ran — meaning
setTimeout(() => console.log(e.target))would printnull. After React 17, pooling was removed, but the delegation model changed: events no longer attach todocumentbut to the root container.In interviews, event handling questions are designed to expose shallow understanding. "What is a SyntheticEvent?" "Why would
e.persist()matter?" "Where does React actually attach event listeners?" "How do you pass arguments to an event handler without creating a new function on every render?"These are not trick questions. They are baseline expectations for mid-level React roles.
In this lesson, I break down synthetic events, event pooling (and why it was removed), event delegation at the root,
e.preventDefault(), passing arguments to handlers, and thethisbinding trap in class components that functional components eliminated entirely.If you have ever been asked "how does React handle events internally?" and hesitated — this one is for you.
Read the full lesson -> [link]
#React #JavaScript #InterviewPrep #Frontend #CodingInterview #EventHandling #SyntheticEvents #100DaysOfCode
What You'll Learn
- What SyntheticEvents are and why React uses them instead of native DOM events
- How event pooling worked before React 17 and why it was removed
- Where React actually attaches event listeners (event delegation model)
- How to use
e.preventDefault()ande.stopPropagation()correctly - How to pass arguments to event handlers without creating functions on every render
- The
thisbinding problem in class components and why functional components avoid it entirely
The Concept — React's Event System
Analogy: The Hotel Front Desk
Imagine a hotel with 200 rooms. Each room has a phone. If every room had a direct outside phone line, you would need 200 separate lines — expensive and wasteful.
Instead, the hotel has one front desk that handles all incoming and outgoing calls. When a call comes in for Room 147, the front desk routes it there. When Room 52 makes a call, it goes through the same front desk.
React's event system works the same way. Instead of attaching an addEventListener to every single button, input, and div in your app, React attaches one listener at the root container (the front desk). When any event fires anywhere in the DOM tree, it bubbles up to the root, and React figures out which component's handler should run.
This is event delegation. One listener handles everything.
SyntheticEvents are the standardized call transcripts the front desk creates. No matter what phone system the caller uses (Chrome, Firefox, Safari), the front desk gives you the same formatted transcript. React wraps native browser events in a SyntheticEvent so your handlers get a consistent interface across all browsers.
Event pooling (pre-React 17) was the front desk reusing the same transcript paper. After your handler finished reading it, the front desk wiped the paper clean and reused it for the next call. If you tried to read it later (in a setTimeout), the paper was already blank. React 17 stopped this practice — you now get your own copy every time.
SyntheticEvents — The Cross-Browser Wrapper
Every event handler in React receives a SyntheticEvent, not a native DOM event. It has the same interface as native events (target, currentTarget, preventDefault(), stopPropagation()) but works identically across browsers.
Code Example 1: SyntheticEvent vs Native Event
import { useRef } from "react";
function EventInspector() {
const buttonRef = useRef(null);
// React handler — receives a SyntheticEvent
function handleReactClick(e) {
console.log("React event type:", e.constructor.name);
// Output: "React event type: SyntheticBaseEvent"
console.log("Target:", e.target.tagName);
// Output: "Target: BUTTON"
console.log("Is native event?", e instanceof MouseEvent);
// Output: "Is native event? false"
// Access the real native event underneath
console.log("Native event:", e.nativeEvent instanceof MouseEvent);
// Output: "Native event: true"
}
// Compare with a native handler added via ref
function attachNativeListener() {
buttonRef.current.addEventListener("click", (e) => {
console.log("Native event type:", e.constructor.name);
// Output: "Native event type: MouseEvent"
console.log("Is native event?", e instanceof MouseEvent);
// Output: "Is native event? true"
});
}
return (
<div>
<button ref={buttonRef} onClick={handleReactClick}>
Click Me
</button>
<button onClick={attachNativeListener}>Attach Native Listener</button>
</div>
);
}
// Clicking "Click Me" logs:
// React event type: SyntheticBaseEvent
// Target: BUTTON
// Is native event? false
// Native event: true
Key point: React normalizes events so you do not need to handle browser inconsistencies. The nativeEvent property gives you the original browser event if you ever need it (rare in practice).
Event Delegation — Where React Attaches Listeners
React does not attach listeners to individual DOM elements. It uses event delegation — attaching a single listener at a higher level that catches all events as they bubble up.
Before React 17: React attached all listeners to document.
React 17 and later: React attaches listeners to the root DOM container (#root).
This change matters when you have multiple React roots on the same page or when you mix React with non-React code.
Code Example 2: Event Delegation and stopPropagation
function App() {
// This handler is NOT attached to the <div> in the DOM
// React attaches one listener at the root container
function handleOuterClick() {
console.log("Outer div clicked");
}
function handleButtonClick(e) {
console.log("Button clicked");
// stopPropagation prevents the event from reaching the outer handler
// This works within React's synthetic event system
e.stopPropagation();
}
return (
<div onClick={handleOuterClick}>
<button onClick={handleButtonClick}>Click Me</button>
</div>
);
}
// Clicking the button:
// Output: "Button clicked"
// "Outer div clicked" does NOT appear — stopPropagation blocked it
// Without e.stopPropagation():
// Output: "Button clicked"
// Output: "Outer div clicked" (event bubbles up)
Interview detail: Since React 17, e.stopPropagation() correctly prevents the event from reaching both other React handlers and native listeners on the root container. Before React 17, because everything was on document, stopping propagation in React could not prevent native document-level listeners from firing.
e.preventDefault() — Stopping Default Browser Behavior
In vanilla HTML, you can return false from an event handler to prevent default behavior. In React, you must call e.preventDefault() explicitly. Returning false does nothing.
Code Example 3: preventDefault in Forms and Links
function LoginForm() {
function handleSubmit(e) {
// Without this, the browser would refresh the page on form submit
e.preventDefault();
console.log("Form submitted via React — no page reload");
// Now handle the submission with JavaScript
const formData = new FormData(e.target);
console.log("Email:", formData.get("email"));
}
function handleLinkClick(e) {
// Without this, the browser would navigate away
e.preventDefault();
console.log("Link clicked but navigation prevented");
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" placeholder="Email" />
<button type="submit">Log In</button>
<a href="https://example.com" onClick={handleLinkClick}>
External Link (prevented)
</a>
</form>
);
}
// Clicking "Log In":
// Output: "Form submitted via React — no page reload"
// Output: "Email: user@example.com"
// Page does NOT refresh
// Clicking the link:
// Output: "Link clicked but navigation prevented"
// Browser does NOT navigate to example.com
Common trap: In vanilla JavaScript, <form onsubmit="return false"> prevents submission. In React, onSubmit={() => false} does nothing. You must call e.preventDefault(). This is an interview favorite because it catches people who learned HTML events first.
Passing Arguments to Event Handlers
A frequent interview question: how do you pass extra data to a handler without creating a new function on every render?
Code Example 4: Three Approaches to Passing Arguments
import { useCallback } from "react";
function UserList({ users, onDelete }) {
// APPROACH 1: Inline arrow function (simplest, but creates a new function per render)
// Fine for most cases — React is fast enough that this rarely matters
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name}
<button onClick={() => onDelete(user.id)}>Delete</button>
{/* New arrow function created on every render for each item */}
</li>
))}
</ul>
);
}
function UserListOptimized({ users, onDelete }) {
// APPROACH 2: data attributes — no extra function per item
function handleDelete(e) {
const userId = e.currentTarget.dataset.userid;
onDelete(userId);
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name}
<button data-userid={user.id} onClick={handleDelete}>
Delete
</button>
{/* Same function reference for every button */}
</li>
))}
</ul>
);
}
function UserItem({ user, onDelete }) {
// APPROACH 3: Extract a child component — cleanest for optimization
const handleClick = useCallback(() => {
onDelete(user.id);
}, [user.id, onDelete]);
return (
<li>
{user.name}
<button onClick={handleClick}>Delete</button>
</li>
);
}
// Usage of approach 3:
// <ul>
// {users.map(user => (
// <UserItem key={user.id} user={user} onDelete={handleDelete} />
// ))}
// </ul>
// All three approaches work. Approach 1 is the default.
// Approach 2 avoids extra closures but reads from the DOM.
// Approach 3 is the cleanest for memoized child components (React.memo).
Interview takeaway: Inline arrow functions are fine in 99% of cases. Only optimize with useCallback or data attributes when profiling shows re-renders are actually a problem. Premature optimization of event handlers is a common anti-pattern that adds complexity for no measurable gain.
this Binding — Class Components vs Functional Components
In class components, event handlers lose their this context by default. This was one of the most confusing parts of React for years. Functional components eliminated the problem entirely.
// === CLASS COMPONENT — the "this" binding trap ===
class SearchBar extends React.Component {
state = { query: "" };
// BUG: "this" is undefined inside the handler
handleChangeBroken(e) {
// TypeError: Cannot read property 'setState' of undefined
this.setState({ query: e.target.value });
}
// FIX 1: Bind in constructor
constructor(props) {
super(props);
this.handleChangeBound = this.handleChangeBound.bind(this);
}
handleChangeBound(e) {
this.setState({ query: e.target.value }); // Works — "this" is bound
}
// FIX 2: Class field with arrow function (most common)
handleChangeArrow = (e) => {
this.setState({ query: e.target.value }); // Works — arrow inherits "this"
};
render() {
return (
<div>
{/* BUG: this.handleChangeBroken loses "this" */}
<input onChange={this.handleChangeBroken} />
{/* FIX 1: explicitly bound in constructor */}
<input onChange={this.handleChangeBound} />
{/* FIX 2: arrow function class field */}
<input onChange={this.handleChangeArrow} />
</div>
);
}
}
// === FUNCTIONAL COMPONENT — no "this" problem at all ===
function SearchBar() {
const [query, setQuery] = useState("");
// Plain function — no "this" involved
function handleChange(e) {
setQuery(e.target.value);
}
return <input value={query} onChange={handleChange} />;
}
// Functional components use closures instead of "this".
// The handler is just a function inside a function — no binding needed.
Why interviewers ask this: The this binding question tests whether you understand JavaScript fundamentals (how this works in regular functions vs arrow functions) and whether you know why the community moved toward functional components. The correct answer connects both.
Common Mistakes
Mistake 1: Calling the handler instead of passing it
function App() {
function handleClick() {
console.log("Clicked!");
}
// WRONG — calls handleClick immediately on render, not on click
return <button onClick={handleClick()}>Click</button>;
// RIGHT — passes the function reference, React calls it on click
return <button onClick={handleClick}>Click</button>;
// RIGHT — if you need to pass arguments, wrap in an arrow function
return <button onClick={() => handleClick()}>Click</button>;
}
// The parentheses () after handleClick INVOKE the function.
// Without them, you pass the function itself.
This mistake fires the handler during rendering, causing unexpected side effects and infinite re-render loops if the handler calls setState.
Mistake 2: Forgetting that returning false does not prevent default behavior
function Form() {
// WRONG — returning false does nothing in React
function handleSubmit(e) {
console.log("Submitted");
return false; // Has NO effect — page still refreshes
}
// RIGHT — explicitly call preventDefault
function handleSubmitFixed(e) {
e.preventDefault(); // Prevents page refresh
console.log("Submitted");
}
return (
<form onSubmit={handleSubmitFixed}>
<button type="submit">Submit</button>
</form>
);
}
Mistake 3: Accessing the event asynchronously without understanding pooling (legacy)
function SearchInput() {
function handleChange(e) {
// In React 16 and earlier, the SyntheticEvent was pooled.
// After this handler finishes, all properties are nullified.
// BUG in React 16: e.target is null inside setTimeout
setTimeout(() => {
console.log(e.target.value); // null in React 16!
}, 1000);
// FIX for React 16: call e.persist() to remove it from the pool
// e.persist();
// setTimeout(() => {
// console.log(e.target.value); // Works after persist()
// }, 1000);
// In React 17+: pooling was removed entirely.
// e.target.value works in setTimeout without persist().
}
return <input onChange={handleChange} />;
}
Even though pooling is gone, interviewers still ask about it to test your understanding of React's evolution. Know what e.persist() did and why it was needed.
Interview Questions
Q: What is a SyntheticEvent in React?
A SyntheticEvent is React's cross-browser wrapper around the native DOM event. It has the same interface as native events (
target,preventDefault(),stopPropagation()) but normalizes behavior across browsers. You can access the underlying native event viae.nativeEvent. React creates SyntheticEvents for all events, not just mouse clicks — keyboard, focus, form, and touch events are all wrapped.
Q: What was event pooling, and why did React remove it in version 17?
Before React 17, React reused SyntheticEvent objects across different events for performance. After your handler finished, React nullified all properties on the event object and put it back in a pool. This meant you could not access event properties asynchronously (in
setTimeout, promises, orsetStatecallbacks) unless you callede.persist(). React 17 removed pooling because the performance benefit was negligible on modern browsers, and it caused confusing bugs. Now every event handler gets its own event object that persists normally.
Q: Where does React attach event listeners? How did this change in React 17?
React uses event delegation — it attaches a single event listener at a container level rather than on each individual DOM node. Before React 17, all listeners were attached to
document. Starting with React 17, listeners attach to the root DOM container (the element you pass tocreateRoot). This change improved compatibility with multiple React roots on the same page and madee.stopPropagation()work correctly between React and non-React code.
Q: How do you pass arguments to an event handler in React?
The most common approach is an inline arrow function:
onClick={() => handleDelete(id)}. This creates a new function on each render, which is fine for most cases. For performance-sensitive lists with memoized children, you can extract a child component and useuseCallback, or usedata-attributes and read frome.currentTarget.dataset. The key interview point is knowing that inline arrows are the pragmatic default and only optimizing when profiling proves it is needed.
Q: Why did class components have a this binding problem with event handlers, and how do functional components avoid it?
In class components, when you pass a method as a callback (
onClick={this.handleClick}), it loses itsthiscontext because JavaScript regular functions do not inheritthisfrom the class when called as plain callbacks. The fixes were: bind in the constructor, or use arrow function class fields (which capturethislexically). Functional components avoid this entirely because they do not usethis. Handlers are just functions inside functions, using closures to access state and props. This is one reason the community adopted functional components so widely.
Quick Reference — Cheat Sheet
+-----------------------------------+-------------------------------------------+
| Concept | Key Point |
+-----------------------------------+-------------------------------------------+
| SyntheticEvent | React's cross-browser event wrapper. |
| | Same API as native events. Access native |
| | event via e.nativeEvent. |
+-----------------------------------+-------------------------------------------+
| Event pooling (pre-React 17) | Events were reused and nullified after |
| | handler. Call e.persist() to keep them. |
| | Removed in React 17 — no longer needed. |
+-----------------------------------+-------------------------------------------+
| Event delegation | React attaches ONE listener at root, not |
| | on each element. React 16: document. |
| | React 17+: root container (#root). |
+-----------------------------------+-------------------------------------------+
| e.preventDefault() | Must call explicitly. return false does |
| | nothing in React (unlike vanilla HTML). |
+-----------------------------------+-------------------------------------------+
| e.stopPropagation() | Stops event from reaching parent React |
| | handlers. Works correctly from React 17+. |
+-----------------------------------+-------------------------------------------+
| Passing arguments | Default: onClick={() => fn(id)}. |
| | Optimize only when profiling shows need. |
| | Options: useCallback, data-attributes. |
+-----------------------------------+-------------------------------------------+
| this binding (class) | Regular methods lose this. Fix: bind in |
| | constructor or use arrow class fields. |
+-----------------------------------+-------------------------------------------+
| this binding (functional) | No this at all. Closures access state |
| | and props. No binding needed. |
+-----------------------------------+-------------------------------------------+
| Handler reference vs call | onClick={fn} passes reference (correct). |
| | onClick={fn()} CALLS it on render (bug). |
+-----------------------------------+-------------------------------------------+
RULE: Always use e.preventDefault() — returning false does nothing in React.
RULE: Inline arrow functions are fine by default — optimize only when measured.
RULE: Functional components eliminate the this binding problem entirely.
Previous: Lesson 4.4 — Batching & Concurrent Features -> Next: Lesson 5.2 — Form Handling Patterns ->
This is Lesson 5.1 of the React Interview Prep Course — 10 chapters, 42 lessons.