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:
- SyntheticEvent and its cross-browser normalization role.
- The difference between
preventDefaultandstopPropagation. - Why
return falsedoesn't work in React. - React 17's event delegation change and the death of event pooling.
- 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.