React – Handling Events

June 30, 20264 min readUpdated 8/18/2026

Event handlers in React look like HTML attributes and behave like JavaScript function references. Nearly every problem people have with them comes from that second half.

<Button onClick={handleAdd}>Add to cart</Button>

camelCase, and the value goes in braces because it is a function, not a string.

The mistake everyone makes once

<Button onClick={handleAdd}>Add to cart</Button>      {/* right — pass the function */}
<Button onClick={handleAdd()}>Add to cart</Button>    {/* wrong — CALLS it during render */}

The second line runs handleAdd immediately, while React is rendering, and gives onClick whatever it returned — usually undefined. The symptom is the handler firing once when the page loads and never again on click. If that function sets state, you get an infinite render loop instead.

So how do you pass an argument? Wrap it in a function that has not been called yet:

<Button size="sm" variant="primary" onClick={() => onSelect(product)}>
  {isPizza ? 'Build it' : 'Add'}
</Button>

() => onSelect(product) is a function. React calls it on click, and only then does onSelect(product) run. This is the standard way to pass arguments and you will write it constantly:

<Button onClick={() => setQuantity(item.lineId, item.quantity - 1)}></Button>
<Button onClick={() => setQuantity(item.lineId, item.quantity + 1)}>+</Button>
<Button onClick={() => removeItem(item.lineId)}>Remove</Button>
<Button onClick={() => setOrderType('DELIVERY')}>Delivery</Button>

The event object

Handlers receive a synthetic event — React's wrapper over the native one, with the same interface and consistent behaviour across browsers. Most of the time you want e.target.value:

<Form.Control
  id={`${formId}-email`}
  type="email"
  required
  value={email}
  onChange={(e) => setEmail(e.target.value)}
  autoComplete="username"
/>

In TypeScript the event is typed for you when the handler is inline — TypeScript infers it from the element. It is only when you extract a named handler that you have to say what it is:

// Inline: `e` is already typed. No annotation needed.
onChange={(e) => setEmail(e.target.value)}

// Extracted: annotate it.
function handleQuantity(e: React.ChangeEvent<HTMLSelectElement>) {
  setQuantity(Number(e.target.value));
}

async function handleSubmit(event: React.FormEvent) {
  event.preventDefault();
  /* … */
}

The types you will meet most: React.ChangeEvent<HTMLInputElement>, React.FormEvent, React.MouseEvent<HTMLButtonElement>, React.KeyboardEvent.

preventDefault

The browser's default action still happens unless you stop it. On a form that means a full page reload, which throws away your entire application:

async function handleSubmit(event: React.FormEvent) {
  event.preventDefault();          // ← without this the page reloads and the app restarts
  try {
    await login(email, password);
    navigate(redirectTo, { replace: true });
  } catch {
    // The error message is already surfaced through AuthContext; nothing to do here.
  }
}

return (
  <Form onSubmit={handleSubmit}>
    {/* … */}
    <Button type="submit" variant="primary" className="w-100" disabled={loading}>
      {loading ? 'Signing in…' : 'Sign in'}
    </Button>
  </Form>
);

Returning false does not work. That is a jQuery and inline-HTML idiom; in React the return value of a handler is ignored. Call preventDefault().

Note also that the handler is on the <form>'s onSubmit, not the button's onClick. That is deliberate — pressing Enter in a text field submits a form but does not click anything, so an onClick handler silently misses keyboard users.

Bubbling, and stopping it

Events propagate up through the component tree just as they do in the DOM. Usually that is what you want; occasionally a click inside a clickable thing needs to not trigger the outer one:

<div onClick={openDetails}>
  <h3>{product.name}</h3>
  <Button
    onClick={(e) => {
      e.stopPropagation();     // the card must not also open
      addToCart(product);
    }}
  >
    Add
  </Button>
</div>

There is one real difference from the DOM worth knowing. React attaches a single listener at the root of your app rather than one per element, and dispatches from there. This is invisible almost always — but it means e.stopPropagation() on a React handler does not stop a listener you attached yourself with addEventListener on document, because yours already ran or ran at a different level. Mixing the two is where that surfaces.

Handlers are just functions

Nothing about them is special. Declare them in the component body, above the return:

function toggleTopping(id: string) {
  setSelectedToppingIds((current) =>
    current.includes(id) ? current.filter((t) => t !== id) : [...current, id],
  );
}

function handleAdd() {
  if (!product) return;

  addItem({ product, size, crust, toppings: selectedToppings, quantity });
  showToast(`${quantity} × ${product.name} added to your cart`);
  onHide();
}

They close over the current props and state, which is why handleAdd can read product, size, crust and quantity without any of them being passed in. That closure is also the source of the "stale value" confusion that Updating State Correctly untangles.

Side effects belong here

Components must stay pure during render, but event handlers are the opposite — they are for side effects. Sending a request, writing to localStorage, showing a toast, navigating: all of it belongs in a handler.

If you find yourself reaching for useEffect to react to a click, stop. The click already happened in a handler; do the work there. That is the most common misuse of effects, and The Component Lifecycle with useEffect comes back to it.

Next

State with useState — what those set… functions are.