React Interview Prep
Events and Forms

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 print null. After React 17, pooling was removed, but the delegation model changed: events no longer attach to document but 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 the this binding 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


Event Handling in React thumbnail


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() and e.stopPropagation() correctly
  • How to pass arguments to event handlers without creating functions on every render
  • The this binding 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.

Event Handling in React visual 1


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.

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.

Event Handling in React visual 2


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 via e.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, or setState callbacks) unless you called e.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 to createRoot). This change improved compatibility with multiple React roots on the same page and made e.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 use useCallback, or use data- attributes and read from e.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 its this context because JavaScript regular functions do not inherit this from the class when called as plain callbacks. The fixes were: bind in the constructor, or use arrow function class fields (which capture this lexically). Functional components avoid this entirely because they do not use this. 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.

On this page