Handling Events
Handling events is how a React app responds to what the user actually does — clicks, keystrokes, form submissions, hovering, and more. React lets you attach event handlers directly in JSX using camelCased props like onClick and onChange, and it wraps every native browser event in a SyntheticEvent so your code behaves consistently across browsers. Because a handler almost always ends by calling a state setter, understanding events really means understanding how user interaction kicks off React’s render cycle. This lesson covers the full event model: syntax, the event object, passing arguments, and the mistakes that trip up nearly every React beginner.
Overview: How Events Work in React
You never call addEventListener yourself in React. Instead, JSX exposes event props — onClick, onChange, onSubmit, onKeyDown, onMouseEnter, and dozens more — each of which takes a function. When you write <button onClick={handleClick}>, you are telling React "when this button is clicked, call handleClick."
Under the hood, React does not attach a listener to every single element you write an event prop on. Instead, it attaches one listener per event type to the root DOM container your app was rendered into (the element passed to createRoot). This is called event delegation. When a native click happens anywhere in the page, it bubbles up through the DOM to that root listener; React inspects where the event originated, matches it against your component tree, wraps the native event in a SyntheticEvent object, and invokes the correct handler with that SyntheticEvent as its argument. Delegation is more efficient than attaching thousands of individual listeners, and it is why React events technically fire slightly after native bubbling reaches the root — a detail that is invisible in almost all real code.
SyntheticEvent normalizes event properties across browsers so you never need special-casing: e.target, e.type, e.preventDefault(), and e.stopPropagation() all behave predictably no matter which browser is running your app.
The typical lifecycle of an interaction looks like this: the user clicks → the browser fires a native click event → it bubbles to React’s root listener → React builds a SyntheticEvent and calls your onClick handler → your handler usually calls a state setter such as setCount → React schedules a re-render → your component function runs again and returns new JSX → React reconciles the new element tree against the previous one, computing the smallest possible diff → React commits only the changed parts to the real DOM. React 18 also batches every state update triggered inside an event handler (and now inside promises and timeouts too) into a single re-render, so calling several setters in one handler still only re-renders once.
One rule matters above all others: an event prop must receive a reference to a function, not the result of calling one. onClick={handleClick} tells React to call it later, on click. onClick={handleClick()} calls it immediately, during render.
Syntax
<button onClick={handleClick}>Click me</button>
- Event prop — always camelCase, e.g.
onClick, never lowercaseonclickor a string. - Handler value — a function reference (
handleClick) or an inline arrow function (() => handleClick(id)) when you need to pass arguments. - Event object — React calls your handler with a SyntheticEvent, conventionally named
eorevent, as its first argument.
| Event prop | Fires on | Typical element |
|---|---|---|
onClick |
a mouse click or tap | button, div, a |
onChange |
an input/select/textarea value changing | input, select, textarea |
onSubmit |
a form being submitted | form |
onKeyDown |
a key being pressed | input, any focusable element |
onMouseEnter / onMouseLeave |
the pointer entering/leaving an element | any element |
onFocus / onBlur |
an element gaining/losing focus | input, button |
Examples
Example 1: A Like Button
import { useState } from "react";
function LikeButton() {
const [likes, setLikes] = useState(0);
function handleClick() {
setLikes((prevLikes) => prevLikes + 1);
}
return (
<button onClick={handleClick}>
Like ({likes})
</button>
);
}
export default LikeButton;
This renders a single button reading "Like (0)". Each click runs handleClick, which calls setLikes with an updater function so the new value is always based on the latest state. React re-renders LikeButton with the updated count, so the button’s text becomes "Like (1)", then "Like (2)", and so on with every click.
Example 2: A Controlled Form
import { useState } from "react";
function SignupForm() {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(null);
function handleChange(e) {
setEmail(e.target.value);
}
function handleSubmit(e) {
e.preventDefault();
setSubmitted(email);
setEmail("");
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={handleChange}
placeholder="you@example.com"
/>
<button type="submit">Subscribe</button>
{submitted && <p>Thanks, {submitted}! We'll be in touch.</p>}
</form>
);
}
export default SignupForm;
This is a controlled input: its value is always driven by the email state, and every keystroke fires onChange, which updates that state so the input reflects what was typed. Submitting the form calls handleSubmit, which calls e.preventDefault() to stop the browser’s native page reload, saves the submitted email, and clears the field. A confirmation paragraph then appears below the form.
Example 3: Passing Arguments to a Handler
import { useState } from "react";
const colors = ["Red", "Green", "Blue"];
function ColorPicker() {
const [selected, setSelected] = useState(null);
function handleSelect(color) {
setSelected(color);
}
return (
<div>
<ul>
{colors.map((color) => (
<li key={color}>
<button onClick={() => handleSelect(color)}>
{color}
</button>
</li>
))}
</ul>
<p>Selected: {selected ?? "none"}</p>
</div>
);
}
export default ColorPicker;
This renders three buttons, Red, Green, and Blue, each inside a keyed list item, followed by a line reading "Selected: none". Because handleSelect needs to know which color was clicked, each button’s onClick is an inline arrow function, () => handleSelect(color), rather than a bare reference — this is the standard way to pass arguments to an event handler. Clicking a button updates the state and the paragraph immediately shows "Selected: Red" (or Green, or Blue).
How It Works Step by Step
Walking through Example 1 from a fresh mount to after a click:
- Mount —
LikeButtonrenders withlikesinitialized to0. React creates the button’s DOM node; no per-button listener is attached, since click handling is delegated to the root. - User click — the native
clickevent bubbles to React’s root listener. React matches it to this button’sonClickprop and builds a SyntheticEvent. - Handler runs —
handleClickis invoked with that SyntheticEvent and callssetLikes, scheduling a re-render. - Re-render —
LikeButtonruns again as a plain function call;likesis now1, and new JSX describing "Like (1)" is produced. - Reconciliation — React diffs the new element tree against the previous one and determines that only the button’s text content changed.
- Commit — React updates just that text node in the real DOM; nothing else on the page is touched.
- Idle — React waits for the next native event to bubble to the root.
Common Mistakes
Mistake 1: Calling the Handler Instead of Passing It
function handleClick() {
console.log("Button clicked");
}
// Runs handleClick immediately during render, not on click
<button onClick={handleClick()}>Click me</button>
The parentheses call handleClick right away, while the component is rendering, and pass its return value (undefined) to onClick — so the button ends up with no click behavior at all. Pass the function reference instead, and only wrap it in an arrow function when you need to pass an argument:
function handleClick() {
console.log("Button clicked");
}
<button onClick={handleClick}>Click me</button>
// To pass an argument, wrap it in an arrow function:
<button onClick={() => handleClick(42)}>Click me</button>
Mistake 2: Forgetting e.preventDefault() in a Form
function handleSubmit() {
console.log("submitted");
}
function ContactForm() {
return (
<form onSubmit={handleSubmit}>
<button type="submit">Send</button>
</form>
);
}
Because handleSubmit never calls e.preventDefault(), the browser still performs its default action after your handler runs: a full page navigation/reload, wiping out any React state. Accept the event and call preventDefault whenever you’re managing submission yourself:
function handleSubmit(e) {
e.preventDefault();
console.log("submitted");
}
function ContactForm() {
return (
<form onSubmit={handleSubmit}>
<button type="submit">Send</button>
</form>
);
}
Mistake 3: Mutating State Inside a Handler
function handleAdd(item) {
items.push(item);
setItems(items);
}
push mutates the existing array in place, so items is the exact same reference before and after. React compares state by reference, sees nothing changed, and skips the re-render — the new item never appears on screen. Always build a new array or object instead:
function handleAdd(item) {
setItems((prevItems) => [...prevItems, item]);
}
Best Practices
- Pass a function reference to event props (
onClick={handleClick}); only use an inline arrow function when you need to pass an argument or extra logic. - Prefix handler names with
handle(handleClick,handleSubmit,handleChange) so their purpose is obvious at a glance. - Always call
e.preventDefault()in anonSubmithandler unless you genuinely want the browser’s native form submission. - Never mutate state or props inside a handler — always create a new array or object and pass it to the setter.
- Use the functional updater form,
setCount((c) => c + 1), whenever the new state depends on the previous state. - Keep JSX readable by moving non-trivial handler logic into a named function outside the return statement instead of a long inline arrow function.
- Remember that a controlled input needs both
valueandonChange— avaluewithout a matchingonChangemakes the field read-only and logs a console warning.
Practice Exercises
- Exercise 1: Build a
ToggleTextcomponent with a boolean piece of state and a button. Clicking the button should show or hide a paragraph of text, and the button’s own label should switch between "Show" and "Hide". - Exercise 2: Build a
CharacterCountercomponent: a controlled text input tied to state, with a line below it reading "X characters remaining" out of a maximum of 50, updating on every keystroke. - Exercise 3: Build a
RatingStarscomponent that renders five buttons representing stars 1 through 5. Clicking one should set and display the chosen rating below, using an inline arrow function to pass each star’s number to the handler.
Summary
- React event props are camelCase (
onClick,onChange,onSubmit) and take a function reference, not the result of calling one. - Native events are wrapped in a SyntheticEvent and handled centrally through delegation at the root container, not through a listener on every individual element.
- Wrap a handler in an arrow function only when you need to pass extra arguments, such as
() => handleSelect(color). - Call
e.preventDefault()in formonSubmithandlers to stop the browser’s native page reload. - Never mutate state directly; always create a new array or object so React detects the change and re-renders.
- A handler that calls a state setter triggers React’s full render → reconcile → commit cycle, updating only what actually changed in the real DOM.
