Event Handling & Synthetic Events
Event Handling & Synthetic Events
React's event system wraps the browser's native events in a cross-browser abstraction called a SyntheticEvent. For most purposes it behaves identically to the native event — but knowing how it works prevents subtle bugs, especially around event pooling, bubbling, and asynchronous handlers.
Attaching Event Handlers
Pass a function to the event prop — not a string like in old HTML:
// ❌ HTML-style string (doesn't work in React)
<button onclick="handleClick()">Click</button>
// ✅ React — pass the function reference
<button onClick={handleClick}>Click</button>
// Or inline arrow function
<button onClick={() => console.log("clicked")}>Click</button>
// With an argument
<button onClick={() => handleDelete(item.id)}>Delete</button>
The function receives a SyntheticEvent as its first argument:
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
console.log(e.target, e.currentTarget);
}
Common Events
// Mouse events
<div onClick={handleClick}>
<div onDoubleClick={handleDoubleClick}>
<div onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}>
<div onMouseDown={handleDragStart} onMouseUp={handleDragEnd}>
// Keyboard events
<input onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onKeyPress={handleKeyPress} />
// Focus events
<input onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} />
// Form events
<form onSubmit={handleSubmit}>
<input onChange={handleChange} />
<select onChange={handleChange}>
// Drag events
<div draggable onDragStart={handleDragStart} onDrop={handleDrop} onDragOver={e => e.preventDefault()}>
// Touch events (mobile)
<div onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
Handling Form Events
The most common event — reading input values:
function ContactForm() {
const [form, setForm] = useState({ name: "", email: "", message: "" });
// Generic handler for all text inputs
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); // prevent page reload
// form.name, form.email, form.message are ready
submitForm(form);
}
return (
<form onSubmit={handleSubmit}>
<input name="name" value={form.name} onChange={handleChange} />
<input name="email" type="email" value={form.email} onChange={handleChange} />
<textarea name="message" value={form.message} onChange={handleChange} />
<button type="submit">Send</button>
</form>
);
}
Keyboard Events
Respond to specific keys:
function SearchInput() {
const [query, setQuery] = useState("");
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
performSearch(query);
}
if (e.key === "Escape") {
setQuery("");
}
// Modifier keys
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
openCommandPalette();
}
}
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
/>
);
}
Event Bubbling and Stopping Propagation
Events bubble up the DOM tree by default. A click on a button inside a card triggers the card's onClick too:
function CourseCard({ course, onCardClick, onEnrollClick }: Props) {
return (
<div onClick={onCardClick} className="cursor-pointer">
<h3>{course.title}</h3>
{/* This click would also trigger onCardClick! */}
<button
onClick={(e) => {
e.stopPropagation(); // stop bubbling to the parent div
onEnrollClick(course.id);
}}
>
Enroll
</button>
</div>
);
}
e.stopPropagation() stops the event from reaching parent handlers.
e.preventDefault() stops the browser's default behavior (form submitting, link navigating, etc.).
They're independent — you can call one, both, or neither.
Event Delegation and Performance
React uses event delegation — it attaches a single listener to the root instead of one per element. This means even a list of 10,000 items has only one event listener:
// This is fine for large lists — React handles it efficiently
function ItemList({ items }: { items: Item[] }) {
function handleClick(id: string) {
setSelected(id);
}
return (
<ul>
{items.map(item => (
// No performance problem having onClick in a large list
<li key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
}
Custom Events Between Components
React doesn't have custom events like the DOM does — instead, pass callback props:
// "Events" in React are just callback functions
function SearchBar({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState("");
return (
<form onSubmit={e => { e.preventDefault(); onSearch(query); }}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button type="submit">Search</button>
</form>
);
}
function CoursesPage() {
const [results, setResults] = useState<Course[]>([]);
async function handleSearch(query: string) {
const data = await fetchCourses({ search: query });
setResults(data);
}
return (
<div>
<SearchBar onSearch={handleSearch} />
<CourseGrid courses={results} />
</div>
);
}
The onClick Trap with Async Functions
// ❌ This looks fine but has a problem
<button onClick={async () => {
setLoading(true);
await submitForm(data);
setLoading(false);
}}>
Submit
</button>
// If the user clicks multiple times quickly, you can get race conditions
// Better: disable while loading
<button
disabled={loading}
onClick={async () => {
setLoading(true);
try {
await submitForm(data);
} finally {
setLoading(false);
}
}}
>
{loading ? "Submitting..." : "Submit"}
</button>
Touch and Pointer Events
For drag-and-drop and touch interfaces:
function DraggableCard({ id, children }: { id: string; children: React.ReactNode }) {
const [isDragging, setIsDragging] = useState(false);
return (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", id);
setIsDragging(true);
}}
onDragEnd={() => setIsDragging(false)}
className={`cursor-grab ${isDragging ? "opacity-50" : ""}`}
>
{children}
</div>
);
}
function DropZone({ onDrop }: { onDrop: (id: string) => void }) {
const [isOver, setIsOver] = useState(false);
return (
<div
onDragOver={e => { e.preventDefault(); setIsOver(true); }}
onDragLeave={() => setIsOver(false)}
onDrop={e => {
e.preventDefault();
setIsOver(false);
const id = e.dataTransfer.getData("text/plain");
onDrop(id);
}}
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
isOver ? "border-blue-500 bg-blue-50" : "border-gray-300"
}`}
>
Drop here
</div>
);
}
Next lesson: Context API and useContext — sharing state across components without prop drilling.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises