Skip to main content

⚑ Event Handling in React

A user interface is only interesting when it reacts. A click that adds an item, a keystroke that filters a list, a submit that saves a form β€” these are events, and events are how your components come alive. In this lesson you'll learn React's way of wiring user actions to code.

Week 4 · Day 3 (Wednesday: Handling Events in React) · Lecture 1

🎯 Learning Objectives

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

  • Attach event handlers in JSX using camelCase props like onClick and onChange
  • Pass a function reference to a handler and pass arguments without calling it on every render
  • Read useful data from the event object (target, key, modifier keys)
  • Use preventDefault() and stopPropagation() deliberately and know the difference
  • Explain event bubbling and capturing, and handle each phase in React
  • Keep handlers efficient with stable references (useCallback) and delegation

Estimated Time: 60 minutes

Practice: Build a click counter, a keyboard-shortcut box, and a modal that closes on outside clicks.

In This Lesson

Why Events Matter

Think of an event as a doorbell. Someone presses it (a user clicks, types, or scrolls), a wire carries the signal, and a bell rings somewhere inside the house (your code runs). React's job is to make that wiring simple and consistent β€” you say "when this button is clicked, run this function," and React handles the browser plumbing underneath.

Every interactive feature you'll build β€” search boxes, dropdowns, drag-and-drop, forms β€” is an event handler that updates state. And because updating state re-renders the component, the loop from action to UI change is the heartbeat of a React app.

graph LR A[User Action
click / type / submit] --> B[React attaches
a SyntheticEvent] B --> C[Your handler runs] C --> D[setState updates data] D --> E[Component re-renders] E --> F[UI reflects the change] F --> A

πŸ“– A note on SyntheticEvent

The e React hands your handler is not the raw browser event β€” it's a lightweight cross-browser wrapper called a SyntheticEvent. It behaves the same in Chrome, Firefox, and Safari, which spares you the browser-specific checks developers used to write. The next lesson is a deep dive into that system; today we just use it.

Attaching Handlers

In plain HTML you'd write onclick="doThing()" β€” a lowercase attribute holding a string. React changes two things: the prop is camelCase (onClick), and you pass an actual function, not a string.

function ButtonExample() {
  // Define the handler once, inside the component.
  function handleClick() {
    alert('Button clicked!');
  }

  // Pass the function REFERENCE β€” note: handleClick, not handleClick()
  return <button onClick={handleClick}>Click me</button>;
}

⚠️ The most common beginner bug

onClick={handleClick} passes the function so React can call it later, on click. onClick={handleClick()} calls it immediately during render and hands React the return value (usually undefined). The symptom: your handler fires once when the page loads and never again on click.

Inline arrow functions

For short, throwaway logic you can define the handler right in the JSX with an arrow function:

function InlineExample() {
  return <button onClick={() => alert('Clicked!')}>Click me</button>;
}

This is fine for tiny handlers. When the logic grows past a line or two, pull it out into a named function β€” it reads better and is easier to debug.

A real handler that updates state

import { useState } from 'react';

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

  // Using the updater form keeps us correct even with rapid clicks.
  const increment = () => setCount(c => c + 1);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}

This is the pattern behind almost everything: an event handler calls setState, and React re-renders with the new value.

Passing Arguments to Handlers

Often you need to tell the handler which thing was acted on β€” which item in a list, which tab, which id. Since onClick wants a function reference, you wrap your call in an arrow function so it runs only when clicked:

function ItemList() {
  const items = [
    { id: 1, name: 'Apples' },
    { id: 2, name: 'Bread' },
    { id: 3, name: 'Coffee' },
  ];

  const handleSelect = (id) => {
    console.log(`Item ${id} selected`);
  };

  return (
    <ul>
      {items.map((item) => (
        // The arrow defers the call: handleSelect runs on click, not on render.
        <li key={item.id} onClick={() => handleSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}

πŸ’‘ Why the wrapper arrow?

onClick={handleSelect(item.id)} would call the function during render for every list item at once. Wrapping it β€” onClick={() => handleSelect(item.id)} β€” hands React a function it can call later, closing over the right item.id.

Getting both the argument and the event

Need the event object and a custom argument? The wrapper arrow receives the event and forwards both:

const handleSelect = (id, e) => {
  console.log('Selected', id, 'via', e.type);
};

<li onClick={(e) => handleSelect(item.id, e)}>{item.name}</li>

The Event Object

Every handler receives an event object (conventionally named e or event). It carries details about what happened: which element, which key, whether Shift was held, and more.

function EventInspector() {
  const handleClick = (e) => {
    console.log('type:', e.type);            // "click"
    console.log('target:', e.target);        // element actually clicked
    console.log('currentTarget:', e.currentTarget); // element with the handler
    console.log('coords:', e.clientX, e.clientY);   // mouse position
    console.log('modifiers:', e.shiftKey, e.ctrlKey, e.altKey);
  };

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

⚠️ target vs currentTarget

e.target is the exact element that triggered the event (maybe a <span> inside a button). e.currentTarget is the element whose handler is running right now. When you attach one handler to a container and clicks come from children, this distinction is what you rely on.

Keyboard events

Keyboard handlers give you the human-readable e.key. Prefer it over the deprecated numeric e.keyCode:

function SearchBox() {
  const handleKeyDown = (e) => {
    if (e.key === 'Enter') {
      console.log('Search submitted');
    }
    if (e.key === 'Escape') {
      e.currentTarget.blur(); // dismiss on Escape
    }
  };

  return <input type="text" onKeyDown={handleKeyDown} placeholder="Search…" />;
}

Preventing Default Behavior

Browsers have built-in reactions: submitting a form reloads the page, clicking a link navigates away, right-clicking opens a context menu. When your component wants to handle those itself, call e.preventDefault() to cancel the browser's default.

function CommentForm() {
  const [text, setText] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();          // stop the full-page reload
    console.log('Submitting:', text);
    // …send to an API, update state, etc.
    setText('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Write a comment"
      />
      <button type="submit">Post</button>
    </form>
  );
}

βœ… The classic use case

Handling onSubmit on the <form> (rather than onClick on the button) is the right pattern β€” it also fires when the user presses Enter in a field. Just remember the e.preventDefault() or the page will reload and wipe your state.

Propagation: Bubbling & Capturing

When you click a button nested inside a couple of <div>s, the event doesn't only fire on the button. It travels: first down from the document to the target (the capturing phase), then back up from the target to the document (the bubbling phase). Handlers on ancestors fire along the way.

Event capturing travels down to the target, then bubbling travels back up document outer <div> <button> (target) 1. capture ↓ 2. bubble ↑
An event captures downward to the target, then bubbles back up. React's onClick listens in the bubble phase; onClickCapture listens on the way down.

Stopping propagation

By default a click on the inner element also triggers handlers on its parents (bubbling). Call e.stopPropagation() to keep the event from travelling further up:

function NestedClicks() {
  const handleOuter = () => console.log('Outer clicked');

  const handleButton = (e) => {
    e.stopPropagation();     // outer handler will NOT fire
    console.log('Button clicked β€” and stopped here');
  };

  return (
    <div onClick={handleOuter} style={{ padding: 20 }}>
      Outer area
      <button onClick={handleButton}>Click me</button>
    </div>
  );
}

Listening in the capture phase

Add Capture to any handler name to run it on the way down instead of up:

<div
  onClickCapture={() => console.log('Outer capture β€” fires first')}
  onClick={() => console.log('Outer bubble β€” fires last')}
>
  <button onClick={() => console.log('Button β€” fires in the middle')}>
    Click
  </button>
</div>
// Order: "Outer capture" β†’ "Button" β†’ "Outer bubble"

πŸ’‘ preventDefault β‰  stopPropagation

They solve different problems. preventDefault() cancels the browser's built-in reaction (navigation, form submit). stopPropagation() stops the event travelling to other handlers. Reach for each independently β€” you rarely need both at once.

Efficient Handlers

Defining a handler inside a component means a brand-new function is created on every render. For most components that's completely fine β€” function creation is cheap. It only becomes worth optimizing when the handler is passed to many memoized children that would otherwise re-render needlessly.

Stable references with useCallback

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

// memo skips re-rendering when props are unchanged…
const ItemButton = memo(function ItemButton({ id, name, onSelect }) {
  console.log('render', name);
  return <button onClick={() => onSelect(id)}>{name}</button>;
});

function Toolbar() {
  const [selected, setSelected] = useState(null);

  // …so we keep onSelect stable across renders with useCallback.
  const handleSelect = useCallback((id) => {
    setSelected(id);
  }, []);

  const items = [
    { id: 'a', name: 'Bold' },
    { id: 'b', name: 'Italic' },
  ];

  return items.map((item) => (
    <ItemButton key={item.id} id={item.id} name={item.name} onSelect={handleSelect} />
  ));
}

⚠️ Don't optimize prematurely

useCallback has a cost too β€” it adds code and a dependency array to keep correct. Only reach for it when you've paired it with memo and measured a real re-render problem. A plain inline arrow on a button is the right default.

One handler for many children (delegation)

Instead of attaching a handler to every list item, attach one to the parent and read e.target to see what was clicked. Data attributes carry the id:

function TodoList({ todos }) {
  const handleClick = (e) => {
    const id = e.target.dataset.id;   // reads data-id from the clicked <li>
    if (id) console.log('Toggled todo', id);
  };

  return (
    <ul onClick={handleClick}>
      {todos.map((t) => (
        <li key={t.id} data-id={t.id}>{t.text}</li>
      ))}
    </ul>
  );
}

React actually uses delegation internally β€” but doing it explicitly like this can simplify very large or frequently changing lists.

Practice & Quiz

πŸ‹οΈ Exercise 1: A click counter with a twist

Goal: Build a Counter that increments on a normal click but decrements when Shift is held during the click. Use the event object to detect the modifier.

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

  const handleClick = (e) => {
    // TODO: if e.shiftKey, subtract 1; otherwise add 1
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Click (Shift to subtract)</button>
    </div>
  );
}
πŸ’‘ Hint

The event object has a boolean e.shiftKey. Use the updater form of setCount so rapid clicks stay correct: setCount(c => c + step).

βœ… Solution
const handleClick = (e) => {
  const step = e.shiftKey ? -1 : 1;
  setCount((c) => c + step);
};

πŸ‹οΈ Exercise 2: Close-on-outside-click modal

Goal: A modal should close when you click the dark backdrop, but not when you click the white dialog inside it. Use propagation control.

πŸ’‘ Hint

Put onClick on the backdrop to close. On the inner dialog, call e.stopPropagation() so its clicks never reach the backdrop.

βœ… Solution
function Modal({ onClose, children }) {
  return (
    <div className="backdrop" onClick={onClose}>
      <div
        className="dialog"
        onClick={(e) => e.stopPropagation()} // clicks inside stay inside
      >
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

🎯 Quick Quiz

Question 1: Which one correctly wires a handler so it runs on click?

Question 2: You want a form's submit to not reload the page. What do you call?

Question 3: Inside a handler, which property is the element that actually triggered the event?

Best Practices & Pitfalls

βœ… Do

  • Pass a function reference (onClick={handleClick}), or a wrapper arrow when you need arguments
  • Handle submissions with onSubmit on the <form> plus e.preventDefault()
  • Use e.key (e.g. 'Enter', 'Escape') for keyboard logic, not the deprecated keyCode
  • Name handlers handleX and the props onX for readable, conventional code

❌ Don't

  • Call the handler in JSX: onClick={handleClick()} fires it once at render, not on click
  • Reach for useCallback/memo everywhere β€” optimize only measured hotspots
  • Confuse stopPropagation() (stops other handlers) with preventDefault() (stops browser default)
  • Forget accessibility: make clickable non-buttons keyboard-reachable, or just use a <button>

⚠️ Stale closures in handlers

// ❌ Reads a possibly stale count
const bad = () => setCount(count + 1);

// βœ… Updater form always sees the latest value
const good = () => setCount((c) => c + 1);

When a handler updates state based on the current state, use the updater function form. It sidesteps the "why is my counter off by one?" bug caused by closing over an old value.

Summary

πŸŽ‰ Key Takeaways

  • React handlers are camelCase props that take a function reference, not a string or a call
  • Wrap in an arrow to pass arguments: onClick={() => handle(id)}
  • The event object carries target, key, coordinates, and modifier flags
  • preventDefault() cancels the browser default; stopPropagation() halts bubbling β€” they're different tools
  • Events bubble up (and capture down); listen on the way down with onXCapture
  • Optimize handlers with useCallback + memo only when a real re-render cost demands it

πŸ“š Additional Resources

πŸš€ What's Next?

You've been using that mysterious e object all lesson. Next we lift the hood: Synthetic Events β€” how React normalizes browser differences, delegates from the root, and what changed when event pooling was removed in React 17.

πŸŽ‰ Nicely done!

Your components can now listen and respond. Every interactive UI you build stands on these handler patterns.