Event handling in React: synthetic events and controlled components
How React events differ from vanilla JavaScript
Event handling in React builds on familiar browser patterns but introduces important differences. If you’re coming from vanilla JavaScript, React’s event system feels familiar but has some key differences. React doesn’t attach event listeners directly to the elements you create , instead, it uses a single root-level listener and delegates all events. This is called the Synthetic Event System.
Here’s the most immediate difference in naming:
// Vanilla JS — lowercase, string handler
<button onclick="handleClick()">Click</button>
// React — camelCase, function reference
<button onClick={handleClick}>Click</button>
In vanilla JS, onclick accepts a string that gets evaluated, or a function reference via addEventListener. In React, you pass the function object directly as a JSX prop. No quotes, no addEventListener call needed.
Another critical difference is onChange. In the browser, a traditional onchange event only fires when an input loses focus after its value changed. In React, onChange fires on every keystroke , the same behavior as the browser’s oninput event. If you expect vanilla JS behavior from React’s onChange, you’ll be surprised.
React also normalizes events across browsers. Chrome, Firefox, Safari, and Edge all have subtle inconsistencies in how they expose events. React’s synthetic events give you a consistent API regardless of which browser your app runs in.
Handling button clicks with onClick
The onClick handler is the most common event you’ll use. You pass it a function that React calls when the element is clicked.
function ButtonClick() {
function handleClick() {
console.log('Button clicked at', new Date().toISOString());
// output: Button clicked at 2026-03-27T10:30:00.000Z
}
return <button onClick={handleClick}>Click me</button>;
}
Defining the handler outside the JSX keeps the component readable when the logic involves more than a single expression. It also makes the function testable in isolation and reusable across multiple elements.
You can also define the handler inline if it’s short:
function InlineButton() {
return (
<button onClick={() => console.log('Clicked!')}>
Click
</button>
);
}
Be careful not to call the function by mistake with parentheses:
Inline handlers are convenient for one-liners like toggling a boolean, but avoid nesting complex logic inside them. When an inline handler grows beyond a single expression, extract it into a named function so the JSX stays scannable and the logic can be tested independently.
// Wrong — calls handleClick immediately on every render
<button onClick={handleClick()}>Wrong</button>
// Correct — passes the function reference
<button onClick={handleClick}>Right</button>
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
Common event attributes
Beyond onClick, React supports a wide range of event attributes matching the browser’s event model, all in camelCase.
onChange , input changes
function SearchInput() {
const [query, setQuery] = React.useState('');
function handleChange(e) {
setQuery(e.target.value);
}
return (
<div>
<input
value={query}
onChange={handleChange}
placeholder="Search..."
/>
<p>You typed: {query}</p>
</div>
);
}
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
onSubmit , form submission
The preventDefault call stops the browser from performing a full page reload on form submission, which is the standard behavior in single-page applications. Without it, React would lose all component state as the page navigates away.
function SimpleForm() {
function handleSubmit(e) {
e.preventDefault();
console.log('Form submitted!');
// output: Form submitted!
}
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Your name" />
<button type="submit">Send</button>
</form>
);
}
onFocus and onBlur , focus management
Focus and blur events are particularly useful for form validation UX. You can show validation messages on blur instead of on every keystroke, which feels less aggressive. Focus handlers also let you highlight the active field or show contextual help text.
function FocusDemo() {
const [focused, setFocused] = React.useState(false);
return (
<input
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={focused ? 'Focused!' : 'Click here'}
/>
);
}
onKeyDown , keyboard events
Key events give you fine-grained control beyond what input change events provide. You can intercept Enter to submit a form, Escape to close a modal, or arrow keys to navigate a dropdown list. The e.key and e.code properties let you distinguish between character keys and modifier keys reliably.
function KeyLogger() {
const [lastKey, setLastKey] = React.useState('');
function handleKeyDown(e) {
setLastKey(e.key);
console.log('Key:', e.key, 'Code:', e.code);
}
return (
<input
onKeyDown={handleKeyDown}
placeholder="Press any key"
/>
);
}
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
Passing arguments to event handlers
Sometimes you need to pass extra data to your handler. There are two common ways.
Arrow Functions
Wrap the handler in an arrow function:
function ItemList() {
function handleClick(id) {
console.log('Clicked item:', id);
// output: Clicked item: 42
}
const items = [
{ id: 1, name: 'Apple' },
{ id: 42, name: 'Banana' },
];
return (
<ul>
{items.map(item => (
<li key={item.id}>
<button onClick={() => handleClick(item.id)}>
{item.name}
</button>
</li>
))}
</ul>
);
}
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
The bind Method
bind() pre-fills arguments. The event object always passes as the last argument automatically:
function HandleClick(id) {
function handleClick(id, event) {
console.log('ID:', id, 'Target:', event.target.tagName);
// output: ID: 7 Target: BUTTON
}
return <button onClick={handleClick.bind(null, 7)}>Click 7</button>;
}
The assertion pattern shown here checks a specific behavior. The test that follows applies the same principle to a different scenario, so you can see how the pattern adapts to various testing needs.
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
Passing both the event and extra data
function handleClick(event, extraData) {
console.log('Event type:', event.type); // 'click'
console.log('Extra:', extraData); // { foo: 'bar' }
}
<button onClick={(e) => handleClick(e, { foo: 'bar' })}>
Click
</button>
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
The event object
React passes a synthetic event object as the first argument to every handler. It has all the properties you’d expect from a native event, normalized for cross-browser consistency.
function EventDemo() {
function handleClick(e) {
console.log('target:', e.target.tagName); // BUTTON
console.log('currentTarget:', e.currentTarget.tagName); // BUTTON
console.log('type:', e.type); // click
console.log('defaultPrevented:', e.defaultPrevented); // false
}
return <button onClick={handleClick}>Click me</button>;
}
The log output here reveals how data moves through the operation, making it easier to trace values at each step. Comparing these results with the previous example clarifies what changed.
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
preventDefault
Stop the browser’s default behavior:
function StopDefault() {
function handleClick(e) {
e.preventDefault();
console.log('Default prevented, no navigation');
}
return (
<a href="https://example.com" onClick={handleClick}>
Go to Example
</a>
);
}
The log output here reveals how data moves through the operation, making it easier to trace values at each step. Comparing these results with the previous example clarifies what changed.
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
stopPropagation
Stop the event from bubbling up to parent elements:
function StopProp() {
function handleDivClick() {
console.log('Div clicked');
}
function handleButtonClick(e) {
e.stopPropagation();
console.log('Button clicked only');
}
return (
<div onClick={handleDivClick}>
<button onClick={handleButtonClick}>Click me</button>
</div>
);
}
Without stopPropagation, clicking the button would log both “Button clicked only” and “Div clicked”. With it, only the button’s handler fires.
Working with forms: controlled components
In React, a controlled component is an input whose value lives entirely in state. Every change updates state, and state updates re-render the input with the new value.
Text Input
function TextForm() {
const [value, setValue] = React.useState('');
function handleChange(e) {
setValue(e.target.value);
}
function handleSubmit(e) {
e.preventDefault();
if (value.trim() === '') {
console.log('Please enter a value');
return;
}
console.log('Submitted:', value);
setValue('');
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={value}
onChange={handleChange}
placeholder="Your name"
/>
<button type="submit">Submit</button>
</form>
);
}
The following code builds on the concepts introduced above, adding another layer of functionality. Seeing the progression from simple to more complex helps clarify when to introduce each technique.
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
Select Dropdown
function FruitSelect() {
const [fruit, setFruit] = React.useState('apple');
return (
<select value={fruit} onChange={(e) => setFruit(e.target.value)}>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry">Cherry</option>
</select>
);
}
The following code builds on the concepts introduced above, adding another layer of functionality. Seeing the progression from simple to more complex helps clarify when to introduce each technique.
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
Checkbox
function ToggleCheckbox() {
const [checked, setChecked] = React.useState(false);
return (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
{checked ? 'Enabled' : 'Disabled'}
</label>
);
}
The next block shows an alternative approach that handles a slightly different scenario. Comparing both implementations side by side helps clarify when to use each pattern.
stopPropagation with nested elements
Event bubbling is one of the most common sources of confusion in React. When an event fires on a child, it bubbles up to all parent elements that have listeners for that event type.
function NestedDemo() {
function handleCardClick() {
console.log('Card clicked');
}
function handleButtonClick(e) {
e.stopPropagation();
console.log('Button clicked — card handler blocked');
}
return (
<div onClick={handleCardClick} style={{ padding: 20, background: '#eee' }}>
<p>Click the button, not the card</p>
<button onClick={handleButtonClick}>Do something</button>
</div>
);
}
If you forget stopPropagation, clicking the button logs both messages. This matters a lot when you’re building things like modal overlays where clicking the backdrop should close the modal, but clicking inside the modal content should not.
Custom events in React
Native browser custom events don’t map to JSX props. You can’t write <div onMyEvent={handler}>. Instead, you attach and dispatch them directly on DOM elements using a ref.
function CustomEventDemo() {
const ref = React.useRef(null);
const [log, setLog] = React.useState([]);
React.useEffect(() => {
function handleGreet(e) {
setLog(prev => [...prev, e.detail.message]);
}
const el = ref.current;
el.addEventListener('greet', handleGreet);
return () => el.removeEventListener('greet', handleGreet);
}, []);
function triggerGreet() {
const event = new CustomEvent('greet', {
detail: { message: 'Hello from custom event!' },
bubbles: true,
});
ref.current.dispatchEvent(event);
}
return (
<div ref={ref}>
<button onClick={triggerGreet}>Say Hello</button>
{log.map((msg, i) => <p key={i}>{msg}</p>)}
</div>
);
}
Custom events are useful for communication between unrelated parts of your component tree, or for integrating third-party libraries that emit events.
Summary
React’s event handling system wraps browser events in a Synthetic Event layer that normalizes behavior across browsers and keeps your JSX clean.
Key things to remember:
- Naming is camelCase ,
onClick,onChange,onSubmit, not lowercase - Pass function references ,
onClick={handleClick}, notonClick={handleClick()} - Event object is the first argument , access it as
ein your handler - Call
e.preventDefault()to stop browser defaults like form submission or link navigation - Call
e.stopPropagation()to prevent events from bubbling to parent elements - Controlled components drive form inputs entirely through state with
onChangeupdating state and thevalueprop setting the display - Custom events require a DOM ref and
addEventListener, they don’t work as JSX props
Event order and bubbling
React’s event layer still follows the browser’s basic flow, so the order of handlers matters. A child can stop propagation, a parent can react to the same event, and the browser default may still run unless you prevent it. Knowing which part of the chain owns each action keeps the interface predictable.
Forms as state machines
Controlled inputs are easier to reason about when you think of them as tiny state machines. User input changes state, state updates the visible value, and submission reads the current state snapshot. That pattern keeps form logic local and makes validation easier to place in one spot.
Integrate non-React widgets
Not every event starts inside JSX. Third-party widgets, custom DOM events, and browser-only APIs still need direct listeners at times. Use refs and cleanup when you need to bridge that gap. The goal is not to force every interaction through the same channel, but to keep the boundary clear so the React tree stays understandable.
Keep form state close
Forms work best when the input, the state, and the submit handler are easy to see in one place. That makes it obvious where validation happens and how the displayed value is produced. Local form state also makes it easier to reset or clear after a successful submit.
Use refs for cross-boundary events
When an event comes from a widget or a DOM API outside React, treat the ref as the bridge. Attach listeners in an effect, clean them up when the component changes, and keep the custom code focused on that boundary. That keeps the React side simple and the integration code contained.
Synthetic events are a boundary
React’s event wrapper gives you a stable place to handle user actions without worrying about browser differences. Use that layer for the common cases, and drop down to DOM listeners only when you need something custom. That split keeps the event story easier to follow.
Keep handlers small
Short handlers are easier to test and easier to read in JSX. If a click does too many things, move the logic into a separate function so the event line stays clear. That keeps the component focused on intent rather than implementation detail.
See Also
- React: Intro and Setup , Set up your first React project from scratch
- React: Components and JSX , Build reusable UI pieces with JSX syntax
- React: Props and State , Learn how data flows through a React application