React Events Basics
11 examples to get you started with React Events -- 7 basic and 4 intermediate.
Search across all documentation pages
11 examples to get you started with React Events -- 7 basic and 4 intermediate.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
No extra packages required -- every handler on this page ships with React. A standard React project (Next.js, Vite, or CRA) is enough.
Two conventions apply to every example:
onClick, onChange, onKeyDown -- not lowercase.Event object (e.target, e.preventDefault(), etc.).Event handlers run on the client. In a Next.js App Router project, put them inside a component marked "use client" (or import that component from a Server Component).
Respond to a button press with a click handler.
"use client";
export default function ClickCounter() {
const handleClick = () => {
alert("Button clicked!");
};
return <button onClick={handleClick}>Click me</button>;
}onClick={handleClick}), not a function call (onClick={handleClick()}).React.MouseEvent with clientX, clientY, shiftKey, etc.onClick fires too unless a child calls e.stopPropagation().<button type="button"> inside a <form> unless you actually want it to submit.Related: Mouse Events -- click, hover, drag, context menu | Events (Fundamentals) -- synthetic events overview | Typing Events --
MouseEvent<HTMLButtonElement>and friends
Capture keystrokes with a controlled input.
"use client";
import { useState } from "react";
export default function NameInput() {
const [name, setName] = useState("");
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
/>
<p>Hello, {name || "stranger"}!</p>
</div>
);
}onChange fires on every keystroke -- unlike native DOM change, which fires only on blur.e.target.value is always a string; parse numbers/booleans explicitly.value in React state -- you have full control over validation and transformation.react-hook-form to avoid a re-render per keystroke.Related: Form Events -- onChange, onInput, onSubmit | Forms (Fundamentals) -- controlled vs. uncontrolled | React Hook Form -- scaling forms beyond a few fields
Respond to key presses, including modifiers and key combinations.
"use client";
import { useState } from "react";
export default function SearchBox() {
const [query, setQuery] = useState("");
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
console.log("Search for:", query);
} else if (e.key === "Escape") {
setQuery("");
}
};
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
/>
);
}e.key (e.g., "Enter", "Escape", "ArrowUp") -- not the deprecated e.keyCode.e.ctrlKey, e.metaKey, e.shiftKey, e.altKey -- handy for shortcuts.useEffect with window.addEventListener("keydown", ...).onKeyDown fires before the character is inserted; onKeyUp fires after release.Related: Keyboard Events -- shortcuts, IME composition, accessibility | useKeyboardShortcut -- reusable global shortcut hook
Track when an element gains or loses focus -- for validation, tooltips, and UI state.
"use client";
import { useState } from "react";
export default function EmailField() {
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const showError = touched && !email.includes("@");
return (
<div>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
/>
{showError && <p role="alert">Please enter a valid email.</p>}
</div>
);
}onFocus fires when the element receives focus; onBlur when it loses focus.focus/blur. Use them on containers, not just inputs.onBlur before showing validation errors so users are not yelled at mid-typing.relatedTarget on the event.Related: Focus Events -- onFocus, onBlur, onFocusIn | Form Accessibility -- ARIA patterns around focus
Build hover interactions without the quirks of native mouseover.
"use client";
import { useState } from "react";
export default function Tooltip() {
const [open, setOpen] = useState(false);
return (
<span
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
style={{ position: "relative" }}
>
Hover me
{open && (
<span style={{ position: "absolute", top: "-1.5rem", left: 0 }}>
Tooltip!
</span>
)}
</span>
);
}onMouseEnter/onMouseLeave fire once when entering/leaving the element -- they do not fire for children, unlike onMouseOver/onMouseOut.onPointerEnter/onPointerLeave for unified input.:hover for purely visual styling -- reserve JS handlers for behavior.Related: Mouse Events -- full mouse API | Pointer Events -- unified mouse + touch + pen | Tooltip Component -- production-ready tooltip
Handle form submission without a full page reload.
"use client";
import { useState, type FormEvent } from "react";
export default function ContactForm() {
const [email, setEmail] = useState("");
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log("Submit:", email);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type="submit">Send</button>
</form>
);
}e.preventDefault() to stop the browser's default page reload.onSubmit to the <form>, not the submit button -- it fires on button clicks and Enter presses.<form action={serverAction}> instead and skip onSubmit.type="submit" button so keyboard users can submit with Enter.Related: Form Events -- onSubmit, onReset, onInvalid | Server Action Forms -- native React 19 form flow
Intercept clipboard operations to transform or block the content.
"use client";
export default function ProtectedText() {
const handleCopy = (e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
e.clipboardData.setData("text/plain", "Copying is disabled here.");
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const pasted = e.clipboardData.getData("text");
console.log("Pasted:", pasted);
};
return (
<div onCopy={handleCopy}>
<p>Try copying this text.</p>
<input onPaste={handlePaste} placeholder="Paste here" />
</div>
);
}e.clipboardData exposes getData (for paste) and setData (for copy/cut).e.preventDefault() before setData to replace the default clipboard content.navigator.clipboard.writeText() instead.Related: Clipboard Events -- full onCopy/onCut/onPaste API | useCopyToClipboard -- reusable "copy" button hook
Handle mouse, touch, and pen with a single set of handlers -- no branching on input type.
"use client";
import { useState } from "react";
export default function Draggable() {
const [pos, setPos] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (dragging) setPos({ x: e.clientX, y: e.clientY });
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.releasePointerCapture(e.pointerId);
setDragging(false);
};
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
position: "absolute",
left: pos.x,
top: pos.y,
width: 60,
height: 60,
background: dragging ? "crimson" : "steelblue",
touchAction: "none",
}}
/>
);
}setPointerCapture(pointerId) keeps receiving pointermove events even if the cursor leaves the element.touch-action: none in CSS so the browser does not steal the gesture for scrolling/zooming.e.pointerType ("mouse", "touch", "pen") only when the interaction really needs to branch.Related: Pointer Events -- capture, coalesced events, pen pressure | Touch Events -- when to still reach for touch-specific APIs | Mouse Events -- legacy counterpart
Load more content when the user reaches the bottom of a scrollable container.
"use client";
import { useState } from "react";
export default function FeedList({ initial }: { initial: string[] }) {
const [items, setItems] = useState(initial);
const [loading, setLoading] = useState(false);
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
if (atBottom && !loading) {
setLoading(true);
fetch(`/api/feed?after=${items.length}`)
.then((r) => r.json())
.then((next: string[]) => setItems((prev) => [...prev, ...next]))
.finally(() => setLoading(false));
}
};
return (
<div onScroll={handleScroll} style={{ height: 400, overflow: "auto" }}>
{items.map((item) => (
<p key={item}>{item}</p>
))}
{loading && <p>Loading more...</p>}
</div>
);
}onScroll fires very frequently -- guard against double-loading with a loading flag or a debounce.scrollHeight - scrollTop - clientHeight rather than exact equality to trigger slightly early.IntersectionObserver on a sentinel element -- fires only when the sentinel actually enters view.onScroll to window via React -- use useEffect + window.addEventListener("scroll", ...) for page-level scroll.Related: Scroll Events -- throttling, sticky headers, scroll restoration | useIntersectionObserver -- preferred pattern for "load more"
Implement HTML5 drag-and-drop between two lists using React's synthetic drag events.
"use client";
import { useState } from "react";
export default function Board() {
const [todo, setTodo] = useState(["Write docs", "Ship feature"]);
const [done, setDone] = useState<string[]>([]);
const handleDragStart = (e: React.DragEvent<HTMLLIElement>, item: string) => {
e.dataTransfer.setData("text/plain", item);
};
const handleDrop = (e: React.DragEvent<HTMLUListElement>) => {
e.preventDefault();
const item = e.dataTransfer.getData("text/plain");
setTodo((prev) => prev.filter((i) => i !== item));
setDone((prev) => [...prev, item]);
};
return (
<div style={{ display: "flex", gap: "2rem" }}>
<ul>
{todo.map((item) => (
<li key={item} draggable onDragStart={(e) => handleDragStart(e, item)}>
{item}
</li>
))}
</ul>
<ul onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
<strong>Done</strong>
{done.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}e.preventDefault() in onDragOver or the drop target will reject the drop.e.dataTransfer.setData(type, value) and read it back with getData -- this is the only safe cross-window bridge.dnd-kit or similar.draggable to the source element; React does not set it for you.Related: Drag & Drop Events -- onDragStart, onDragOver, onDrop | dnd-kit -- ergonomic drag-and-drop with touch support
Track when a video is ready to play and when it finishes.
"use client";
import { useRef, useState } from "react";
export default function VideoPlayer({ src }: { src: string }) {
const ref = useRef<HTMLVideoElement>(null);
const [status, setStatus] = useState<"loading" | "ready" | "ended">("loading");
return (
<div>
<video
ref={ref}
src={src}
controls
onLoadedMetadata={() => setStatus("ready")}
onEnded={() => setStatus("ended")}
/>
<p>Status: {status}</p>
{status === "ended" && (
<button onClick={() => ref.current?.play()}>Replay</button>
)}
</div>
);
}onLoadedMetadata fires once the browser knows the video's duration and dimensions -- safe to read ref.current.duration.onEnded fires when playback reaches the end; onPlay, onPause, onTimeUpdate cover the rest of the lifecycle.<video> or <audio> element.onTransitionEnd and onAnimationEnd give you the same finish signal.Related: Media & Animation Events -- full media + animation event reference | useRef -- accessing the underlying DOM node
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥