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. click → onClick, keydown → onKeyDown).
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 tohref.- 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
addEventListenerdirectly ondocument— 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 Components interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.