Skip to content

React · Components

React Event Handling — A Complete Interview Guide

7 min read Updated 2026-06-23 Share:

Practice Event Handling interview questions

How React's event system works

React's event system is one of those areas where the surface looks simple (write onClick, get a callback) but the internals have important implications that interviewers probe regularly.

The key insight: React does not attach event listeners directly to individual DOM nodes. Instead it registers a single listener at the root (the document.getElementById('root') container) and catches all events via bubbling. This is event delegation — the same pattern used by jQuery and virtual scroll libraries.

Prior to React 17, all events delegated to document. In React 17+, they delegate to the React root element — a change that makes it safe to embed multiple React apps on one page without cross-app event interference.

The basics: attaching handlers

Pass a function reference as a camelCase prop. No parentheses — you're passing the function, not calling it.

function ClickCounter() {
  const [count, setCount] = useState(0)

  function handleClick() {
    setCount(c => c + 1)
  }

  // onClick receives the function reference
  return <button onClick={handleClick}>Clicked {count} times</button>
}

The most common mistake: onClick={handleClick()} — this calls handleClick during render and passes its return value (likely undefined) as the prop.

SyntheticEvent

Every React event handler receives a SyntheticEvent — a cross-browser wrapper around the native DOM event. It exposes the same interface (target, currentTarget, preventDefault(), stopPropagation(), etc.) but works consistently across all browsers.

function Input() {
  function handleChange(e) {
    // e is a SyntheticEvent with the same API as a native InputEvent
    console.log(e.target.value)
    console.log(e.type)          // 'change'
    console.log(e.nativeEvent)   // the raw browser Event object
  }
  return <input onChange={handleChange} />
}

React 17 change — pooling removed: before React 17, synthetic events were pooled and reused. After the handler returned, React reset all properties to null. Accessing e.target.value inside a setTimeout would crash. The fix was e.persist().

React 17 removed pooling entirely. Synthetic events are now normal objects — you can access them asynchronously without e.persist(). If you see e.persist() in modern code, it's a legacy habit (it's a no-op now).

preventDefault and stopPropagation

These are two completely independent calls:

  • e.preventDefault() — stops the browser's built-in action for this event (form POST, link navigation, checkbox toggle). The event continues to bubble.
  • e.stopPropagation() — stops the event from bubbling further up the DOM. Parent handlers won't fire. The browser action is unaffected.
function Nav() {
  return (
    <nav onClick={() => console.log('nav clicked')}>
      <a
        href="/profile"
        onClick={e => {
          e.preventDefault()    // don't navigate to /profile
          e.stopPropagation()   // don't trigger nav's onClick
          openModal('profile')
        }}
      >
        Profile
      </a>
    </nav>
  )
}

You cannot return false to achieve this — unlike raw HTML onclick handlers, returning false from a React handler has no effect. React ignores the return value.

Call preventDefault() before any await — after the event handler yields, the browser may have already run the default action.

Passing arguments to handlers

Wrap the handler in an arrow function to forward arguments:

function ItemList({ items, onRemove }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          {item.name}
          <button onClick={() => onRemove(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  )
}

The arrow function creates a new function per render. For large lists where this is measurably expensive, use data-* attributes to avoid per-item closures:

function handleRemove(e) {
  const id = e.currentTarget.dataset.id
  onRemove(id)
}

<button data-id={item.id} onClick={handleRemove}>Remove</button>

Profile before optimizing — for most lists the closure approach is fine.

Controlled inputs and onChange

React's onChange fires on every value change — typing, pasting, cutting — not just on blur like the native DOM change event. This makes React's onChange equivalent to the native input event.

Controlled inputs wire value to state and onChange to a state setter:

function SearchBox() {
  const [query, setQuery] = useState('')

  return (
    <input
      type="search"
      value={query}
      onChange={e => setQuery(e.target.value)}
      placeholder="Search…"
    />
  )
}

Every keystroke triggers a re-render. The DOM value attribute stays in sync with state — programmatic changes (setQuery('')) work correctly.

Keyboard events

Use onKeyDown for keyboard handling. Check e.key for the logical key name (not the deprecated e.keyCode):

function CommandInput() {
  function handleKeyDown(e) {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      submitCommand()
    }
    if (e.key === 'Escape') {
      clear()
    }
  }

  return <textarea onKeyDown={handleKeyDown} />
}

Modifier keys: check e.ctrlKey, e.shiftKey, e.altKey, e.metaKey. onKeyPress is deprecated — use onKeyDown instead.

Form submission

Always put onSubmit on the <form> element, not onClick on the submit button. The form's handler fires for both mouse clicks and Enter-key presses.

function SignupForm() {
  const [email, setEmail] = useState('')

  async function handleSubmit(e) {
    e.preventDefault()          // prevent full-page POST
    await createAccount(email)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
      />
      <button type="submit">Sign up</button>
    </form>
  )
}

Debouncing and throttling

Debounce/throttle wrappers must be stable across renders — don't recreate them on every render or the timer resets each time.

function SearchInput({ onSearch }) {
  const debouncedSearch = useRef(
    debounce((value) => onSearch(value), 300)
  ).current

  useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch])

  return (
    <input
      onChange={e => debouncedSearch(e.target.value)}
      placeholder="Search…"
    />
  )
}

useRef stores the debounced function once. The cleanup useEffect cancels any pending timer on unmount.

This binding in class components

In class components, event handlers lose their this when called as callbacks. Three fixes, in order of preference:

// Option 1 — class field arrow function (auto-binds, no constructor needed)
class Button extends React.Component {
  handleClick = () => {
    this.setState({ clicked: true })
  }
  render() {
    return <button onClick={this.handleClick}>Click</button>
  }
}

// Option 2 — bind in constructor
class Button extends React.Component {
  constructor(props) {
    super(props)
    this.handleClick = this.handleClick.bind(this)
  }
  handleClick() { … }
}

// Option 3 — arrow function in JSX (creates a new function each render)
<button onClick={() => this.handleClick()}>

Function components don't have this at all — one less thing to worry about.

What interviewers are testing

Event questions are checking whether you understand:

  1. SyntheticEvent and its cross-browser normalization role.
  2. The difference between preventDefault and stopPropagation.
  3. Why return false doesn't work in React.
  4. React 17's event delegation change and the death of event pooling.
  5. How to pass arguments without calling the handler immediately.

The practical questions (form handling, controlled inputs, keyboard events) are checking whether you can write correct React UI code, not just recite concepts.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel