Event handlers in React look like HTML attributes and behave like JavaScript function references. Nearly every problem people have with them comes from that second half.
<Button onClick={handleAdd}>Add to cart</Button>camelCase, and the value goes in braces because it is a function, not a string.
The mistake everyone makes once
<Button onClick={handleAdd}>Add to cart</Button> {/* right — pass the function */}
<Button onClick={handleAdd()}>Add to cart</Button> {/* wrong — CALLS it during render */}The second line runs handleAdd immediately, while React is rendering, and gives
onClick whatever it returned — usually undefined. The symptom is the
handler firing once when the page loads and never again on click. If that function sets state, you
get an infinite render loop instead.
So how do you pass an argument? Wrap it in a function that has not been called yet:
<Button size="sm" variant="primary" onClick={() => onSelect(product)}>
{isPizza ? 'Build it' : 'Add'}
</Button>() => onSelect(product) is a function. React calls it on click, and only then does
onSelect(product) run. This is the standard way to pass arguments and you will write it
constantly:
<Button onClick={() => setQuantity(item.lineId, item.quantity - 1)}>−</Button>
<Button onClick={() => setQuantity(item.lineId, item.quantity + 1)}>+</Button>
<Button onClick={() => removeItem(item.lineId)}>Remove</Button>
<Button onClick={() => setOrderType('DELIVERY')}>Delivery</Button>The event object
Handlers receive a synthetic event — React's wrapper over the native one, with the same interface
and consistent behaviour across browsers. Most of the time you want e.target.value:
<Form.Control
id={`${formId}-email`}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
/>In TypeScript the event is typed for you when the handler is inline — TypeScript infers it from the element. It is only when you extract a named handler that you have to say what it is:
// Inline: `e` is already typed. No annotation needed.
onChange={(e) => setEmail(e.target.value)}
// Extracted: annotate it.
function handleQuantity(e: React.ChangeEvent<HTMLSelectElement>) {
setQuantity(Number(e.target.value));
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
/* … */
}The types you will meet most: React.ChangeEvent<HTMLInputElement>,
React.FormEvent, React.MouseEvent<HTMLButtonElement>,
React.KeyboardEvent.
preventDefault
The browser's default action still happens unless you stop it. On a form that means a full page reload, which throws away your entire application:
async function handleSubmit(event: React.FormEvent) {
event.preventDefault(); // ← without this the page reloads and the app restarts
try {
await login(email, password);
navigate(redirectTo, { replace: true });
} catch {
// The error message is already surfaced through AuthContext; nothing to do here.
}
}
return (
<Form onSubmit={handleSubmit}>
{/* … */}
<Button type="submit" variant="primary" className="w-100" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</Button>
</Form>
);Returning false does not work. That is a jQuery and inline-HTML idiom;
in React the return value of a handler is ignored. Call preventDefault().
Note also that the handler is on the <form>'s onSubmit, not the
button's onClick. That is deliberate — pressing Enter in a text field submits a form but
does not click anything, so an onClick handler silently misses keyboard users.
Bubbling, and stopping it
Events propagate up through the component tree just as they do in the DOM. Usually that is what you want; occasionally a click inside a clickable thing needs to not trigger the outer one:
<div onClick={openDetails}>
<h3>{product.name}</h3>
<Button
onClick={(e) => {
e.stopPropagation(); // the card must not also open
addToCart(product);
}}
>
Add
</Button>
</div>There is one real difference from the DOM worth knowing. React attaches a single listener at the
root of your app rather than one per element, and dispatches from there. This is invisible almost
always — but it means e.stopPropagation() on a React handler does not stop a listener
you attached yourself with addEventListener on document, because yours
already ran or ran at a different level. Mixing the two is where that surfaces.
Handlers are just functions
Nothing about them is special. Declare them in the component body, above the return:
function toggleTopping(id: string) {
setSelectedToppingIds((current) =>
current.includes(id) ? current.filter((t) => t !== id) : [...current, id],
);
}
function handleAdd() {
if (!product) return;
addItem({ product, size, crust, toppings: selectedToppings, quantity });
showToast(`${quantity} × ${product.name} added to your cart`);
onHide();
}They close over the current props and state, which is why handleAdd can read
product, size, crust and quantity without any of
them being passed in. That closure is also the source of the "stale value" confusion that
Updating State Correctly untangles.
Side effects belong here
Components must stay pure during render, but event handlers are the opposite — they are
for side effects. Sending a request, writing to localStorage, showing a toast,
navigating: all of it belongs in a handler.
If you find yourself reaching for useEffect to react to a click, stop. The click
already happened in a handler; do the work there. That is the most common misuse of effects, and
The Component Lifecycle with useEffect comes back to it.
Next
State with useState — what those set… functions
are.