React Events Basics
11 examples to get you started with React Events -- 7 basic and 4 intermediate.
Prerequisites
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:
- Event names use camelCase in JSX:
onClick,onChange,onKeyDown-- not lowercase. - Handlers receive a synthetic event that normalizes browser differences; its API matches the native
Eventobject (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).
Basic Examples
1. onClick
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>;
}- Pass a function reference (
onClick={handleClick}), not a function call (onClick={handleClick()}). - The handler receives a
React.MouseEventwithclientX,clientY,shiftKey, etc. - Clicks bubble up through the tree -- a parent
onClickfires too unless a child callse.stopPropagation(). - Use
<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
2. onChange on a Text Input
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>
);
}onChangefires on every keystroke -- unlike native DOMchange, which fires only on blur.e.target.valueis always a string; parse numbers/booleans explicitly.- A controlled input keeps
valuein React state -- you have full control over validation and transformation. - For large forms, prefer
react-hook-formto 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
3. onKeyDown
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..."
/>
);
}- Use
e.key(e.g.,"Enter","Escape","ArrowUp") -- not the deprecatede.keyCode. - Check modifiers with
e.ctrlKey,e.metaKey,e.shiftKey,e.altKey-- handy for shortcuts. - For global shortcuts (not tied to an input), attach a listener in
useEffectwithwindow.addEventListener("keydown", ...). onKeyDownfires before the character is inserted;onKeyUpfires after release.
Related: Keyboard Events -- shortcuts, IME composition, accessibility | useKeyboardShortcut -- reusable global shortcut hook
4. onFocus and onBlur
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>
);
}onFocusfires when the element receives focus;onBlurwhen it loses focus.- React versions of these events bubble -- unlike native
focus/blur. Use them on containers, not just inputs. - Typical pattern: wait for
onBlurbefore showing validation errors so users are not yelled at mid-typing. - For tab-trap, menu-close, and accessibility flows, combine with the native
relatedTargeton the event.
Related: Focus Events -- onFocus, onBlur, onFocusIn | Form Accessibility -- ARIA patterns around focus
5. onMouseEnter and onMouseLeave
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/onMouseLeavefire once when entering/leaving the element -- they do not fire for children, unlikeonMouseOver/onMouseOut.- Touch devices do not fire hover events; provide an alternative (tap, focus) for accessibility.
- For hover state that lasts across children, pair with
onPointerEnter/onPointerLeavefor unified input. - Prefer CSS
:hoverfor 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
6. onSubmit
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>
);
}- Call
e.preventDefault()to stop the browser's default page reload. - Attach
onSubmitto the<form>, not the submit button -- it fires on button clicks and Enter presses. - For native form action flow (React 19+), use
<form action={serverAction}>instead and skiponSubmit. - Include a real
type="submit"button so keyboard users can submit with Enter.
Related: Form Events -- onSubmit, onReset, onInvalid | Server Action Forms -- native React 19 form flow
7. onCopy and onPaste
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.clipboardDataexposesgetData(for paste) andsetData(for copy/cut).- Call
e.preventDefault()beforesetDatato replace the default clipboard content. - Never use this for security -- determined users can still extract text from the DOM.
- To copy text programmatically from a button, use
navigator.clipboard.writeText()instead.
Related: Clipboard Events -- full onCopy/onCut/onPaste API | useCopyToClipboard -- reusable "copy" button hook
Intermediate Examples
8. Pointer Events for Unified Input
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",
}}
/>
);
}- Pointer events fire for mouse, touch, and pen -- one code path replaces separate mouse and touch handlers.
setPointerCapture(pointerId)keeps receivingpointermoveevents even if the cursor leaves the element.- Set
touch-action: nonein CSS so the browser does not steal the gesture for scrolling/zooming. - Check
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
9. Infinite Scroll with onScroll
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>
);
}onScrollfires very frequently -- guard against double-loading with aloadingflag or a debounce.- Compute "near the bottom" from
scrollHeight - scrollTop - clientHeightrather than exact equality to trigger slightly early. - For cleaner code, use
IntersectionObserveron a sentinel element -- fires only when the sentinel actually enters view. - Do not attach
onScrolltowindowvia React -- useuseEffect+window.addEventListener("scroll", ...)for page-level scroll.
Related: Scroll Events -- throttling, sticky headers, scroll restoration | useIntersectionObserver -- preferred pattern for "load more"
10. Native Drag and Drop
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>
);
}- You must call
e.preventDefault()inonDragOveror the drop target will reject the drop. - Pass payload through
e.dataTransfer.setData(type, value)and read it back withgetData-- this is the only safe cross-window bridge. - Native drag-and-drop is clunky on mobile -- for rich interactions or touch support, reach for
dnd-kitor similar. - Add
draggableto 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
11. Media Playback Events
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>
);
}onLoadedMetadatafires once the browser knows the video's duration and dimensions -- safe to readref.current.duration.onEndedfires when playback reaches the end;onPlay,onPause,onTimeUpdatecover the rest of the lifecycle.- Media events do not bubble -- attach them directly to the
<video>or<audio>element. - For CSS transitions and JS animations,
onTransitionEndandonAnimationEndgive you the same finish signal.
Related: Media & Animation Events -- full media + animation event reference | useRef -- accessing the underlying DOM node