Skip to main content

πŸ–±οΈ React Event Handling System

Every button click, keystroke, and form submit in a React app flows through one clean, cross-browser event system. This lesson shows you how React's SyntheticEvents work, how to write handlers that stay fast, and how events travel through your component tree.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a SyntheticEvent is and how React's event system differs from raw DOM events
  • Attach handlers for the common mouse, keyboard, and form event types
  • Pass arguments to event handlers without breaking performance
  • Control event propagation with stopPropagation, capture-phase handlers, and preventDefault
  • Avoid needless re-renders using useCallback and React.memo

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a keyboard-driven counter with propagation control and a memoized child.

In This Lesson

Events in React

An event is something that happens in the browser that your code can respond to: a click, a key press, a mouse moving, a form being submitted. React lets you respond to these by passing a function to a JSX prop like onClick β€” no addEventListener, no manual cleanup.

If you've written vanilla JavaScript, React's approach will feel familiar but tidier. Instead of finding an element and attaching a listener, you declare the handler right where the element lives:

function LikeButton() {
  const handleClick = () => {
    console.log('Liked!');
  };

  // Pass the FUNCTION, not a call β€” no parentheses
  return <button onClick={handleClick}>πŸ‘ Like</button>;
}

⚠️ Pass a function, don't call it

onClick={handleClick} hands React the function to run later. onClick={handleClick()} calls it immediately during render and passes the return value β€” a classic bug that fires your handler on every render instead of on click.

From interaction to re-render A user interaction becomes a DOM event, is wrapped as a React SyntheticEvent, runs the handler, updates state, and triggers a re-render. Interaction DOM event Synthetic Event Handler State β†’ re-render
Figure 1 β€” The path from a user interaction to a re-rendered UI. React inserts its SyntheticEvent wrapper between the raw DOM event and your handler.

Key differences from raw DOM events

  • React props use camelCase β€” onClick, not onclick; onMouseEnter, not onmouseenter.
  • You pass a function reference, not a string of code.
  • React wraps native events in a SyntheticEvent for consistent cross-browser behavior.
  • Returning false does not prevent default behavior β€” you must call event.preventDefault() explicitly.

The SyntheticEvent System

React does not attach a separate listener to every element. Instead, it attaches listeners at the root of your app and uses event delegation internally. When an event fires, React wraps the native event in a SyntheticEvent object and routes it to your handler.

πŸ’‘ Analogy: Think of the SyntheticEvent as a universal power adapter. Different browsers historically "used different plugs" for events; React's adapter gives your code one consistent shape to work with, no matter which browser the user is on.

A SyntheticEvent follows the same W3C interface as a native event, so the properties you already know are all there:

function Button() {
  const handleClick = (event) => {
    console.log('Event type:', event.type);       // "click"
    console.log('Target element:', event.target);  // the <button>
    console.log('Native event:', event.nativeEvent); // the raw browser event
  };

  return <button onClick={handleClick}>Inspect me</button>;
}

πŸ“– Key Terms

SyntheticEvent: React's cross-browser wrapper around a native DOM event.

nativeEvent: the underlying browser event, reachable via event.nativeEvent when you need something React doesn't surface.

Event delegation: attaching one listener high in the tree that handles events for many descendants β€” the technique React uses under the hood.

πŸ’‘ Event pooling is gone

Older tutorials warn that you must call event.persist() because React "pools" and reuses event objects. That pooling was removed in React 17. On React 18/19 you can safely read event properties inside async callbacks β€” no persist() needed.

Common Event Types

React exposes the whole family of DOM events as camelCased props. Here are the ones you'll reach for most.

Mouse events

PropFires when…
onClickan element is clicked
onDoubleClickan element is double-clicked
onMouseEnter / onMouseLeavethe pointer enters / leaves an element (no bubbling)
onMouseMovethe pointer moves within an element
import { useState } from 'react';

function HoverTracker() {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  const [hovering, setHovering] = useState(false);

  const handleMouseMove = (e) => {
    setPos({ x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY });
  };

  return (
    <div
      onMouseMove={handleMouseMove}
      onMouseEnter={() => setHovering(true)}
      onMouseLeave={() => setHovering(false)}
      style={{ width: 300, height: 160, border: '1px solid var(--border-color)' }}
    >
      <p>Pointer: {pos.x}, {pos.y}</p>
      {hovering && <p>Hovering!</p>}
    </div>
  );
}

Keyboard events

PropFires when…
onKeyDowna key is pressed down (use this for shortcuts)
onKeyUpa key is released

⚠️ onKeyPress is deprecated

The old onKeyPress / keypress event is deprecated and doesn't fire for non-character keys. Use onKeyDown and inspect event.key (e.g. 'Enter', 'Escape', 'ArrowLeft').

function NoDigitsInput() {
  const [value, setValue] = useState('');

  const handleKeyDown = (e) => {
    // Block digit keys while still allowing Backspace, arrows, etc.
    if (/^\d$/.test(e.key)) {
      e.preventDefault();
    }
  };

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
      onKeyDown={handleKeyDown}
      placeholder="Letters only"
    />
  );
}

Form & focus events

PropFires when…
onChangean input's value changes (fires on every keystroke in React)
onSubmita form is submitted
onFocus / onBluran element gains / loses focus

Forms get a full lesson of their own next β€” here we'll just note that onSubmit handlers almost always start with e.preventDefault() to stop the browser reloading the page.

Writing Event Handlers

Inline vs. named handlers

For a one-liner, an inline arrow function is perfectly fine. Once the logic grows past a line or two, pull it out into a named function so your JSX stays readable:

// Fine for trivial logic
<button onClick={() => setOpen(true)}>Open</button>

// Better once there's real work to do
function Panel() {
  const handleOpen = () => {
    setOpen(true);
    logAnalytics('panel_opened');
    focusFirstField();
  };
  return <button onClick={handleOpen}>Open</button>;
}

Passing arguments to a handler

When you need to pass extra data β€” like which list item was clicked β€” wrap the call in an arrow function so it runs on the event, not during render:

function ItemList() {
  const items = ['Apple', 'Banana', 'Cherry'];

  const handleItemClick = (item, index, event) => {
    console.log(`Clicked ${item} at index ${index}`, event.type);
  };

  return (
    <ul>
      {items.map((item, index) => (
        <li key={item} onClick={(e) => handleItemClick(item, index, e)}>
          {item}
        </li>
      ))}
    </ul>
  );
}

βœ… Use a stable key

Notice key={item} rather than key={index}. When list contents can be reordered or filtered, an index key confuses React's reconciler. Prefer a value that uniquely and stably identifies the row.

sequenceDiagram participant User participant Element participant Handler participant State User->>Element: Clicks Element->>Handler: Calls handler with SyntheticEvent Handler->>State: setState(...) State->>Element: React re-renders with new value

Propagation, Capture & Default

Just like native DOM events, React events travel through the tree in phases: they capture downward from the root to the target, then bubble back up from the target to the root.

Capture and bubble phases Nested boxes for root, parent, and target. An arrow travels down through them during the capture phase and back up during the bubble phase. Root Parent Target (button) capture ↓ bubble ↑
Figure 2 — Capture runs root→target, then bubble runs target→root. Default React handlers listen on the bubble phase; add the Capture suffix for the capture phase.

Stopping propagation

Call event.stopPropagation() to keep an event from reaching ancestor handlers β€” handy when a clickable card contains its own clickable button:

function Card({ onOpen }) {
  return (
    <div onClick={onOpen} style={{ cursor: 'pointer' }}>
      <h3>Report</h3>
      <button
        onClick={(e) => {
          e.stopPropagation(); // don't also trigger onOpen
          downloadReport();
        }}
      >
        Download
      </button>
    </div>
  );
}

Capture-phase handlers

Append Capture to any event prop to run during the capture phase instead of the bubble phase:

<div
  onClickCapture={() => console.log('outer capture (runs first)')}
  onClick={() => console.log('outer bubble (runs last)')}
>
  <button onClick={() => console.log('button bubble')}>Click</button>
</div>
// Order: outer capture β†’ button bubble β†’ outer bubble

Preventing default behavior

Use event.preventDefault() to cancel the browser's built-in action β€” submitting a form, following a link, or scrolling on spacebar:

function NewsletterForm() {
  const handleSubmit = (e) => {
    e.preventDefault();       // stop the full-page reload
    console.log('Submitting via fetch instead');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" required />
      <button type="submit">Subscribe</button>
    </form>
  );
}
πŸ’‘ Analogy: Bubbling is like an announcement echoing outward through nested rooms. stopPropagation() soundproofs a room so the echo stays inside; preventDefault() is more like telling the building "ignore your usual fire-drill response to that alarm."

Handler Performance

Every render creates fresh inline handler functions. That's usually harmless β€” but when you pass a handler to a child wrapped in React.memo, a new function each render defeats the memoization and re-renders the child anyway.

The problem and the fix

import { useState, useCallback, memo } from 'react';

// Child only re-renders when its props change by reference
const Child = memo(function Child({ onClick }) {
  console.log('Child rendered');
  return <button onClick={onClick}>Increment</button>;
});

function Parent() {
  const [count, setCount] = useState(0);

  // useCallback keeps the SAME function reference across renders
  const handleClick = useCallback(() => {
    setCount((c) => c + 1); // functional update β€” no `count` dependency
  }, []);

  return (
    <>
      <p>Count: {count}</p>
      <Child onClick={handleClick} />
    </>
  );
}

βœ… Why the empty dependency array works

By calling setCount(c => c + 1) β€” the functional update form β€” the handler never needs to read count directly, so it has no dependencies and useCallback can safely memoize it once. The memoized Child then skips re-rendering when only count changes.

⚠️ Don't reach for useCallback everywhere

useCallback has its own cost and adds noise. It pays off when (1) you pass the handler to a memoized child, or (2) the handler is a dependency of another hook like useEffect. For a plain <button onClick={...}> in the same component, an inline arrow is simpler and just as fast.

Hands-on Exercise

πŸ‹οΈ Build a Keyboard-Aware Counter

Objective: Combine several event concepts into one small component.

Requirements:

  1. Show a count that starts at 0 and can never go below zero.
  2. Provide Increment, Decrement, and Reset buttons.
  3. Also let the user press ArrowUp / ArrowDown anywhere in the widget to change the count.
  4. Extract the increment handler with useCallback and pass it to a memoized button child.
πŸ’‘ Hint

Give the outer <div> tabIndex={0} so it can receive keyboard focus, then add onKeyDown and branch on e.key. For "never below zero," clamp with Math.max(0, c - 1) inside the functional update.

βœ… Sample solution
import { useState, useCallback, memo } from 'react';

const StepButton = memo(function StepButton({ label, onClick }) {
  return <button onClick={onClick}>{label}</button>;
});

function Counter() {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = () => setCount((c) => Math.max(0, c - 1));
  const reset = () => setCount(0);

  const handleKeyDown = (e) => {
    if (e.key === 'ArrowUp') { e.preventDefault(); increment(); }
    if (e.key === 'ArrowDown') { e.preventDefault(); decrement(); }
  };

  return (
    <div
      tabIndex={0}
      onKeyDown={handleKeyDown}
      style={{ padding: 16, border: '1px solid var(--border-color)' }}
    >
      <p>Count: {count}</p>
      <StepButton label="οΌ‹" onClick={increment} />
      <button onClick={decrement}>βˆ’</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

🎯 Quick Quiz

Question 1: What is a React SyntheticEvent?

Question 2: You pass a handler to a React.memo child and it still re-renders every time the parent renders. What most likely fixes it?

Question 3: A button inside a clickable card triggers the card's handler too. How do you stop that?

Best Practices

βœ… Do❌ Avoid
Pass a function reference: onClick={handleClick}Calling it during render: onClick={handleClick()}
Use onKeyDown + event.keyThe deprecated onKeyPress
Call preventDefault() in onSubmitRelying on a full-page reload to send data
Reach for useCallback when passing to memoized childrenWrapping every handler in useCallback reflexively
Use stable, meaningful key values in listsArray index keys on reorderable lists

πŸ’‘ A note on class components

You may still see class components that bind handlers in the constructor so this works. Function components with hooks are the modern default and sidestep the this problem entirely β€” that's what we use throughout this course.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Attach handlers with camelCase props and pass a function reference, not a call.
  • React wraps native events in a SyntheticEvent; the raw event is at event.nativeEvent. Event pooling was removed in React 17.
  • Prefer onKeyDown with event.key; onKeyPress is deprecated.
  • Control flow with stopPropagation(), Capture-suffixed props, and preventDefault().
  • Use useCallback + React.memo to avoid needless child re-renders β€” but only where it actually pays off.

πŸ“š Further Reading

πŸš€ What's Next?

Now that you can respond to any interaction, the next lesson focuses on the interaction developers handle most: forms. We'll manage input state, wire up submission, and validate what users type.

πŸŽ‰ Well handled!

You can now make React apps respond to clicks, keys, and everything in between β€” and keep them fast doing it.