React SME Cookbook
All FAQs
basicsreact-eventsexamplessynthetic-events

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:

  1. Event names use camelCase in JSX: onClick, onChange, onKeyDown -- not lowercase.
  2. Handlers receive a synthetic event that normalizes browser differences; its API matches the native 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).


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.MouseEvent with clientX, clientY, shiftKey, etc.
  • Clicks bubble up through the tree -- a parent onClick fires too unless a child calls e.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>
  );
}
  • 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.
  • A controlled input keeps value in React state -- you have full control over validation and transformation.
  • For large forms, prefer 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


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 deprecated e.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 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


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>
  );
}
  • onFocus fires when the element receives focus; onBlur when it loses focus.
  • React versions of these events bubble -- unlike native focus/blur. Use them on containers, not just inputs.
  • Typical pattern: wait for onBlur before showing validation errors so users are not yelled at mid-typing.
  • For tab-trap, menu-close, and accessibility flows, combine with the native relatedTarget on 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/onMouseLeave fire once when entering/leaving the element -- they do not fire for children, unlike onMouseOver/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/onPointerLeave for unified input.
  • Prefer CSS :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


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 onSubmit to 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 skip onSubmit.
  • 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.clipboardData exposes getData (for paste) and setData (for copy/cut).
  • Call e.preventDefault() before setData to 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 receiving pointermove events even if the cursor leaves the element.
  • Set touch-action: none in 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>
  );
}
  • onScroll fires very frequently -- guard against double-loading with a loading flag or a debounce.
  • Compute "near the bottom" from scrollHeight - scrollTop - clientHeight rather than exact equality to trigger slightly early.
  • For cleaner code, use IntersectionObserver on a sentinel element -- fires only when the sentinel actually enters view.
  • Do not attach 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"


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() in onDragOver or the drop target will reject the drop.
  • Pass payload through e.dataTransfer.setData(type, value) and read it back with getData -- 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-kit or similar.
  • Add 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


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>
  );
}
  • 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.
  • Media events do not bubble -- attach them directly to the <video> or <audio> element.
  • For CSS transitions and JS animations, onTransitionEnd and onAnimationEnd give you the same finish signal.

Related: Media & Animation Events -- full media + animation event reference | useRef -- accessing the underlying DOM node