Skip to content

Event Handling Interview Questions & Answers

17 questions Updated 2026-06-23 Share:

React event handling interview questions — SyntheticEvent, camelCase events, preventDefault, stopPropagation, passing arguments, controlled inputs, and event delegation.

Read the in-depth guideReact Event Handling — A Complete Interview Guide(opens in new tab)
17 of 17

Pass a function reference as a camelCase prop on the JSX element. React registers the handler for you via its internal event system.

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

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

  return <button onClick={handleClick}>Clicked {count} times</button>
}

Common pitfalls:

  • onClick={handleClick()} — calls the function immediately during render and passes its return value. Correct form omits the ().
  • Arrow functions inline are fine but create a new function each render, which can matter for memoized children.

Rule of thumb: name handlers handleEventName by convention; pass them as onEventName={handler} — no parentheses.

React wraps native browser events in a SyntheticEvent — a cross-browser wrapper that normalizes differences in the native event API (e.g. IE vs Chrome). It exposes the same interface as the native event (target, preventDefault(), stopPropagation(), etc.) but works consistently everywhere.

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

React 17 change: prior to React 17, synthetic events were pooled (reused after the handler returned, with all properties set to null). In React 17+, pooling was removed — you can safely access the event asynchronously.

Rule of thumb: treat a SyntheticEvent exactly like a native DOM event. If you need the raw event, it's on e.nativeEvent.

Because JSX is JavaScript, not HTML. In HTML, event attributes are lowercase strings (onclick, onmouseover). In JSX, they are JavaScript property names on the props object, and the React team chose camelCase as the convention to match JavaScript idioms.

// HTML
// <button onclick="handler()">

// JSX
<button onClick={handler}>

// More examples
<input onChange={handle} onFocus={handle} onBlur={handle} />
<form onSubmit={handle} />
<div onMouseEnter={handle} onMouseLeave={handle} />

Rule of thumb: every DOM event is available in React as on + PascalCase (e.g. clickonClick, keydownonKeyDown).

Call e.preventDefault() on the SyntheticEvent inside your handler.

function LoginForm() {
  function handleSubmit(e) {
    e.preventDefault()          // stops the full-page form POST
    // ... handle submission with fetch
  }
  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" />
      <button type="submit">Log in</button>
    </form>
  )
}

Common use cases:

  • <form> onSubmit — prevent page reload.
  • <a> onClick — prevent navigation to href.
  • Drag events — prevent the default drop behavior.

Rule of thumb: call preventDefault() at the top of your handler before any async work — after an await, the event may have already been handled by the browser.

In plain HTML event attributes (onclick="return false"), returning false was a shortcut that both prevented the default and stopped propagation. React does not support this shortcut.

React event handlers are regular JavaScript functions — returning false from them has no special meaning to React's event system.

// ❌ Doesn't work in React — return value is ignored
<a href="/home" onClick={() => false}>Go</a>

// ✅ Explicit calls are required
<a
  href="/home"
  onClick={e => {
    e.preventDefault()
    e.stopPropagation()
  }}
>
  Go
</a>

Rule of thumb: always call e.preventDefault() and/or e.stopPropagation() explicitly; never rely on return values.

They control two completely different things:

  • e.preventDefault() — stops the browser from running its built-in action for the event (e.g. form submission, link navigation, checkbox toggle). The event still bubbles up through the DOM.

  • e.stopPropagation() — stops the event from bubbling further up the DOM tree. Parent handlers won't fire. Does not affect the browser action.

function List() {
  return (
    <ul onClick={() => console.log('list clicked')}>
      <li>
        <a
          href="/detail"
          onClick={e => {
            e.preventDefault()    // don't navigate to /detail
            e.stopPropagation()   // don't trigger the ul's onClick
            navigate('/modal')
          }}
        >
          View
        </a>
      </li>
    </ul>
  )
}

Rule of thumb: call preventDefault to suppress browser behavior; call stopPropagation to prevent parent handlers from also running. You often need both for nested interactive elements.

Wrap the handler in an arrow function or use .bind. The arrow-function approach is more common in JSX.

function ItemList({ items }) {
  function handleDelete(id, e) {
    e.stopPropagation()
    deleteItem(id)
  }

  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          {item.name}
          {/* Arrow wrapper — creates a new fn per render */}
          <button onClick={e => handleDelete(item.id, e)}>Delete</button>
        </li>
      ))}
    </ul>
  )
}

For performance-sensitive lists, you can use data attributes instead:

function handleDelete(e) {
  const id = e.currentTarget.dataset.id
  deleteItem(id)
}

<button data-id={item.id} onClick={handleDelete}>Delete</button>

Rule of thumb: arrow wrappers are fine for most use cases. Use data-* attributes on large lists where creating many closures is measurably expensive.

React does not attach individual event listeners directly to each DOM node. Instead, it attaches a single listener at the root and uses event bubbling to catch all events — this is called event delegation.

Prior to React 17, all events were delegated to document. In React 17+, they are delegated to the root DOM container (document.getElementById('root')). This change makes it safer to embed multiple React apps on one page or mix React with non-React code.

// Conceptually what React does internally (simplified)
rootElement.addEventListener('click', react_internal_handler)
// When a <button> inside is clicked, the event bubbles up to root,
// React checks which component was the target, and calls your onClick.

This is largely transparent to application code, but matters when:

  • Calling e.stopPropagation() — stops bubbling before it reaches the React root, so React never sees the event.
  • Using addEventListener directly on document — fires after React's handler in React 17+.

Rule of thumb: rely on React's event system rather than attaching your own listeners to document; it handles delegation and cleanup for you.

Attach onSubmit to the <form> element, call e.preventDefault() to stop the page reload, then read values from controlled state or uncontrolled refs.

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

  async function handleSubmit(e) {
    e.preventDefault()
    await signUp({ email, password })
  }

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

Rule of thumb: always put onSubmit on <form>, not onClick on the submit button — the form's handler fires for Enter key presses as well as button clicks.

A controlled input has its value (or checked) driven by React state. React becomes the single source of truth for the input's content.

// Controlled — React owns the value
const [text, setText] = useState('')
<input value={text} onChange={e => setText(e.target.value)} />

// Uncontrolled — DOM owns the value, React reads it on demand
const ref = useRef()
<input ref={ref} />
// later: ref.current.value

Why React prefers controlled:

  • You can validate or transform input on every keystroke.
  • Input state is immediately available in component scope — no need to query the DOM.
  • The input reflects state exactly, so programmatic resets work: setText('') clears the field.

Rule of thumb: use controlled inputs by default; use uncontrolled when integrating with non-React DOM libraries or when you only need the value at submission.

In the browser, input fires on every value change (typing, pasting, cutting), while change fires only when the element loses focus after a value change.

React normalizes this: React's onChange fires on every value change (like the native input event), making it consistent regardless of element type. React's onChange and native oninput are effectively equivalent for text inputs.

// Fires on every keystroke in React (not just on blur like native 'change')
<input onChange={e => console.log(e.target.value)} />

React does expose onInput as well, but for controlled inputs onChange is the standard and recommended handler.

Rule of thumb: use onChange in React for real-time input tracking. Knowing that it behaves like native oninput matters when comparing React behavior with vanilla JS.

Use onKeyDown, onKeyUp, or onKeyPress (deprecated — avoid). Read e.key for the logical key name or e.code for the physical key.

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

  function handleKeyDown(e) {
    if (e.key === 'Enter') {
      search(query)
    }
    if (e.key === 'Escape') {
      setQuery('')
    }
  }

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
      onKeyDown={handleKeyDown}
    />
  )
}

For accessibility, elements that handle keyboard events should also be focusable (native interactive elements are by default; custom elements need tabIndex={0} and appropriate ARIA roles).

Rule of thumb: check e.key (logical, locale-aware) rather than e.keyCode (deprecated numeric code). For modifier keys, check e.ctrlKey, e.shiftKey, e.altKey, e.metaKey.

In a class component, this inside a regular method is undefined when called as an event handler (because it's passed as a callback — detached from the instance). There are three common fixes:

class Counter extends React.Component {
  constructor(props) {
    super(props)
    this.state = { n: 0 }
    // Option 1: bind in constructor
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick() {
    this.setState({ n: this.state.n + 1 })
  }

  render() {
    return <button onClick={this.handleClick}>{this.state.n}</button>
  }
}

// Option 2: class field arrow function (auto-binds)
class Counter extends React.Component {
  state = { n: 0 }
  handleClick = () => {
    this.setState({ n: this.state.n + 1 })
  }
  render() {
    return <button onClick={this.handleClick}>{this.state.n}</button>
  }
}

// Option 3: arrow in render (new function per render)
<button onClick={() => this.handleClick()}>

Rule of thumb: class fields (option 2) is the cleanest in modern JS. In function components, this issue doesn't exist at all — another reason to prefer them.

Wrap the handler with a throttle/debounce utility (lodash is most common) but memoize the wrapper with useRef or useCallback so you get a stable function reference across renders.

import { useCallback, useRef } from 'react'
import debounce from 'lodash/debounce'

function SearchInput({ onSearch }) {
  // useRef keeps the debounced fn stable across renders
  const debouncedSearch = useRef(
    debounce((value) => onSearch(value), 300)
  ).current

  // Clean up on unmount
  useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch])

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

Common mistake: creating the debounced function inside the component body without memoization — a new function (with a fresh timer) is created each render, so debouncing never actually fires.

Rule of thumb: store debounced/throttled functions in useRef (not recreated on render) and cancel them on unmount to avoid calling stale closures.

Use e.nativeEvent to get the underlying browser Event object.

function DragZone() {
  function handleDrop(e) {
    e.preventDefault()
    // SyntheticEvent doesn't expose dataTransfer directly in all React versions
    const files = e.nativeEvent.dataTransfer?.files
    console.log(files)
  }
  return <div onDrop={handleDrop} onDragOver={e => e.preventDefault()}>Drop here</div>
}

Reasons you might need nativeEvent:

  • Accessing properties not forwarded by the SyntheticEvent wrapper.
  • Passing the event to a non-React library that checks instanceof Event.
  • Calling browser-only APIs like getCoalescedEvents() for pointer events.

Rule of thumb: start with the SyntheticEvent API; only reach for nativeEvent when you need a property or method that the wrapper doesn't expose.

Instead of passing a unique arrow function per item (which creates N closures), use a single handler and identify the target via data-* attributes or e.currentTarget.

function TodoList({ todos, onToggle }) {
  function handleClick(e) {
    const id = e.currentTarget.dataset.id
    onToggle(id)
  }

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <button data-id={todo.id} onClick={handleClick}>
            {todo.text}
          </button>
        </li>
      ))}
    </ul>
  )
}

For most lists, arrow functions per item are fine — the overhead is negligible. The data-* pattern is worth applying when the list is very long or when the handlers are passed to React.memo children.

Rule of thumb: profile before optimizing. Use data-* delegation when you have hundreds of interactive rows and can measure the improvement.

Before React 17, React pooled SyntheticEvents to reduce garbage collection pressure. After your handler returned, React would reset all properties to null and return the event object to the pool.

// Pre-React 17 bug
function handleChange(e) {
  setTimeout(() => {
    console.log(e.target.value)  // CRASH — e.target is null after handler exits
  }, 100)
}

// Pre-React 17 fix: persist the event
function handleChange(e) {
  e.persist()  // remove from pool, keep properties
  setTimeout(() => {
    console.log(e.target.value)  // safe
  }, 100)
}

React 17 removed event pooling. SyntheticEvents are now regular objects that are not recycled. Accessing them asynchronously is safe, and e.persist() is a no-op (still exists to avoid breaking old code).

Rule of thumb: in React 17+ projects you don't need e.persist(). If you see it in a codebase, it's either legacy code or a defensive habit from older React.

More ways to practice

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

or
Join our WhatsApp Channel