Event Handling in React
How React's event system works, why it differs from vanilla JS, and how to handle clicks, form inputs, and keyboard events correctly — including TypeScr...
When I first moved from vanilla JavaScript to React, event handling felt like familiar territory. You attach a handler to an element, something happens, your function runs. Easy.
Then I started running into odd behaviour. My onChange fired on every keystroke. My onSubmit needed e.preventDefault() in a different place than I expected. I kept writing addEventListener out of habit. It took me a while to build a proper mental model of how React handles events and why it works the way it does.
How React's event system works
React does not attach event listeners directly to DOM nodes. Instead, it uses a single listener at the root of the document (or the React root) and routes events through its own synthetic event system.
When you write this:
<button onClick={handleClick}>Click me</button>
React is not calling button.addEventListener('click', handleClick). It is registering a synthetic onClick handler that React manages centrally and calls when the browser's native click event bubbles up to the root.
The object you receive in your handler is a SyntheticEvent — a wrapper around the native browser event that normalises differences across browsers. For most purposes it behaves exactly like the native event.
One consequence: event pooling used to be an issue in React 16 and earlier, where the synthetic event object was reused between calls. React 17 dropped event pooling, so you no longer need to call e.persist() when accessing events asynchronously. The e you receive is safe to use in a setTimeout or Promise without any extra steps.
Attaching handlers
React event names are camelCase, not lowercase. onclick becomes onClick. onsubmit becomes onSubmit. You pass a function reference, not a function call:
// Correct — pass the function reference
<button onClick={handleClick}>Click</button>
// Wrong — this calls the function immediately during render
<button onClick={handleClick()}>Click</button>
If you need to pass arguments, wrap the call in an arrow function:
<button onClick={() => handleDelete(item.id)}>Delete</button>
This is fine for most cases. If the component renders very frequently and you need to avoid recreating the arrow function on each render, useCallback can help — but do not reach for it by default.
Typing event handlers in TypeScript
Getting TypeScript right with event handlers trips people up the first time. The key is knowing which event type to use.
For a button click:
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
}
For an input change:
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setValue(e.target.value);
}
For a form submit:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
// process form data
}
The pattern is React.{EventType}<{HTMLElement}>. If you are not sure of the exact type, hovering over an inline handler in VS Code will show you what React expects.
Preventing default behaviour
Some elements have default browser behaviour — forms submit and reload the page, anchor tags navigate. Call e.preventDefault() inside your handler to stop the browser's default action:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
console.log("form handled by React, no page reload");
}
return <form onSubmit={handleSubmit}>...</form>;
e.stopPropagation() is the other common one. It stops the event bubbling further up the DOM tree. Use it when you have nested clickable elements and only want the inner handler to fire:
function Card({ onSelect }: { onSelect: () => void }) {
return (
<div onClick={onSelect}>
<button
onClick={(e) => {
e.stopPropagation(); // don't trigger onSelect
doSomethingElse();
}}
>
Action
</button>
</div>
);
}
onChange vs onInput
In HTML, oninput fires on every character and onchange fires when the input loses focus. React's onChange behaves like the native oninput — it fires on every keystroke. This is intentional: React controlled inputs need to stay in sync with state on every change, not just on blur.
If you want something that only fires on blur, use onBlur:
<input
onBlur={(e) => validateField(e.target.value)}
onChange={(e) => setValue(e.target.value)}
/>
Keyboard events
For keyboard shortcuts or accessible interactions, use onKeyDown:
function SearchInput() {
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
performSearch();
}
if (e.key === "Escape") {
clearSearch();
}
}
return <input onKeyDown={handleKeyDown} />;
}
Use e.key rather than e.keyCode. The keyCode property is deprecated and e.key gives you readable string values like "Enter", "Escape", "ArrowDown".
Inline handlers vs named functions
Both work. Named functions are easier to test in isolation and show up with useful names in stack traces. Inline arrow functions are convenient for short, one-off handlers. The main thing to avoid is doing heavy work inside an inline handler when a named function makes the intent clearer:
// Hard to scan at a glance
<button
onClick={() => {
fetch("/api/submit", { method: "POST", body: JSON.stringify(data) })
.then((r) => r.json())
.then(setResult);
}}
>
Submit
</button>
// Easier to understand
<button onClick={handleSubmit}>Submit</button>
What changed in how I think about events
The biggest shift for me was understanding that React owns the event wiring. I stopped writing useEffect with addEventListener for things that could just be onClick or onKeyDown props. That pattern belongs in vanilla JS — in React, attach handlers directly to JSX elements and let React manage the rest.
The one exception is events that have no JSX equivalent: window.resize, document.visibilitychange, scroll position on window. Those still need addEventListener inside a useEffect with proper cleanup. But for anything on a rendered element, the JSX prop is the right place.
Newsletter
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.