skip to content

Effects vs Event Handlers

Most code people put in effects does not belong there: derived values should be computed during render, and work caused by a user action belongs in the handler that caused it. Interviewers love 'do you even need an effect here?' because deleting the effect usually deletes the bug too.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In a React component, a Buy button must POST an order and then show a confirmation toast. One version does both inside the onClick handler; another sets `ordered` state in the handler and runs `useEffect(() => { if (ordered) { postOrder(); showToast(); } }, [ordered])`. Which is correct, and why?

level: juniorimportance: must knowfreq 68%

answer

  1. ask what caused this code to run
  2. user action versus staying in sync
  3. handlers run once per click
  4. the flag exists only for the effect
  5. remount re-fires the effect, not the click

basics

~20 s

Both belong in the onClick handler: they are caused by a specific user action, not by the component being displayed. Routing them through state and an effect adds a render pass, an extra state field, and a path for the request to fire twice.

solid answer

~50 s

Put both in the handler. The test is: what caused this code to run? If it was a specific interaction — the click on Buy — it belongs in that interaction's handler. Effects exist to synchronize with something outside React that must stay in sync for as long as the component is on screen, like a connection or a subscription. The effect version costs an extra state field and an extra render pass, and worse, the effect can run again without a second click: a remount, a back-navigation that restores the flag, or React's development-mode StrictMode double-invoke can send the order twice. Handler code runs exactly once per click, can `await` the response, and can branch on the outcome — toast on success, error state on failure — without inventing state whose only reader is an `if` at the top of an effect.

code

jsx · 27 lines
jsx
import { useState } from 'react';
import { postOrder, showToast } from './api';

export function BuyButton({ productId }) {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState(null);

  async function handleClick() {
    setPending(true);
    setError(null);
    try {
      await postOrder(productId);
      showToast('Order placed');
    } catch (err) {
      setError(err.message);
    } finally {
      setPending(false);
    }
  }

  return (
    <>
      <button onClick={handleClick} disabled={pending}>Buy</button>
      {error && <p role="alert">{error}</p>}
    </>
  );
}

go deeper

for a junior

Be ready to say plainly that work caused by a click belongs in that click's handler, and that useEffect is for keeping something outside React in sync while the component is displayed.

for a middle

Explain the mechanics: the flag costs a commit and a paint before the request even starts, and an effect can run again on remount or in development StrictMode, so one click can produce two orders.

for a senior

Show the production failure — duplicated orders after a back-navigation, errors with nowhere to surface — and demonstrate the handler version with pending state, try/catch/finally and a disabled button.

for a principal

Own the standard for the codebase: define when an effect is justified at all, and treat "we added a ref guard so it only fires once" as a review signal that the work is sitting in the wrong place entirely.

## The question that decides where code goes Every piece of logic in a component runs for one of two reasons. Either it runs **because something specific happened** — the user clicked, typed, submitted, dropped a file — or it runs **because the component is on screen** and something outside React has to be kept in step with it. React gives each reason its own home: event handlers for the first, effects for the second. "Buy was clicked" is unambiguously the first kind. Nothing about the component being visible implies an order should exist; a particular interaction does. So the POST and the toast are handler code. An effect is the right tool for the second kind: a chat connection that must be open while the room is displayed, a `resize` listener that must exist while the widget is mounted, a third-party map instance that must mirror the current props. ## Why the state-plus-effect version is worse **It invents state that means nothing.** `ordered` is not a fact about the UI — nothing renders differently because of it. It exists only so an effect has something to react to. State that has exactly one reader, an `if` at the top of an effect, is an event that was smuggled in through the render cycle. **It costs a render and a paint.** The handler sets `ordered`, React re-renders and commits, the browser paints, and only then does the effect run and start the request. The user waits one extra frame for work that could have started in the handler synchronously. **It creates a second way for the request to fire.** This is the real defect. A handler runs once per click, full stop. An effect runs after mount and after any commit where a dependency changed — and React does not promise that a component mounts only once. Concretely: - In development, `<StrictMode>` mounts, unmounts and remounts components, so the effect body runs twice and two orders are posted. - If `ordered` lives above the component (in a parent, a store, or a route loader's state) and the user navigates away and back, the component remounts with `ordered` already `true` and posts again. - Any future dependency added to that array becomes a new trigger for a network write. Teams usually notice the duplicate, then paper over it with a `useRef` guard — which is the same bug wearing a hat, because a fresh mount gets a fresh ref. **Error handling has nowhere to go.** In a handler you can `try`/`catch`/`finally` around an `await`. Inside an effect you must declare an inner async function, and the failure has to be lowered back into state before anything can react to it. ## What the handler version looks like ```jsx async function handleClick() { setPending(true); try { await postOrder(productId); showToast('Order placed'); } catch (err) { setError(err.message); } finally { setPending(false); } } ``` One cause, one call site, one place to read to know what a click does. `pending` and `error` are real state: they change what is rendered. React 19 also lets a `<form action={fn}>` own the submit path, but that is still the same principle — the work hangs off the interaction, not off a render. ## Where an effect legitimately re-enters this story A click can still *lead* to an effect, indirectly. Clicking "Join room" sets `roomId`; an effect keyed on `roomId` opens the connection and closes it on cleanup. The difference is what the effect is for: it is not performing the click's action, it is keeping an external system matched to the state the click produced, and it will run again — correctly — for any other reason `roomId` changes, including a deep link or a restored session. That is the discriminator to say out loud: **handler code answers "do this now", effect code answers "while this is true, keep that in sync".** A POST is a one-time action; a socket is an ongoing relationship. ## The review heuristic When you meet an effect, ask: *if I delete this effect and call the function from the handler instead, what stops working?* If the answer is "nothing", the effect was a detour. Two syntactic smells find most of them without reading the logic: state whose only consumer is an effect's condition, and an effect whose body opens with `if (someFlag)`. Both mean an event was recorded as state so that an effect could notice it — an indirection that buys nothing and adds a re-fire path.

  • Is there any case where that click legitimately leads to an effect?
    Yes, indirectly. If the click sets state that describes what should be on screen, and something outside React must follow that state, an effect is right. Clicking "Join room" sets `roomId`; an effect connects to that room and disconnects on cleanup. The click causes the state change; the effect keeps the connection matched to it, whatever changed `roomId`.
  • Once the POST lives in the handler, where do loading and error states live?
    In ordinary state that the handler drives: set pending before the await, clear it in a `finally`, store the failure in a `catch` so the UI can render it. Those are real state because they change what is rendered, unlike a flag whose only reader is an effect's condition.
  • How do you spot this antipattern quickly when reviewing a component?
    Look for two shapes: state whose only consumer is an effect's `if`, and an effect body that opens with a boolean guard. Both mean an event was stored as state so an effect could react to it. Then ask what breaks if the effect is deleted and the function is called from the handler — usually nothing.

An event handler is a doorbell — it rings once because someone pressed it. An effect is a thermostat — it keeps running as long as the room exists, comparing state to the world and correcting it.

saying these in an interview costs you the question

  • Says every network call must live inside useEffect
  • Adds a boolean state purely so an effect can react to it
  • Blames the duplicate POST on StrictMode instead of placement
  • Thinks effects are React's only legal place for side effects
  • Claims event handlers cannot contain async or await

context

open as a page

In a React component, one effect sets state, a second effect depends on that state and sets more state, and a third reacts to that. What does this chain of effects cost, and how would you restructure it?

level: middleimportance: should knowfreq 52%

basics

~20 s

Each link costs its own render and commit, so users can briefly see half-updated screens and the logic becomes order-dependent and hard to trace. Compute what you can while rendering, and do the rest inside the single event that started the chain.

open as a page

A React component sends a welcome email from an effect and, after it went out twice, a teammate guarded it with a ref: `useEffect(() => { if (!sentRef.current) { sentRef.current = true; sendWelcomeEmail(userId); } }, [userId])`. Why is that guard the wrong fix, and what would you do instead?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The guard hides a placement error: sending a welcome email is caused by the signup completing, not by a component being displayed, so it belongs in that handler or on the server. A ref is also recreated on remount, so the duplicates come back.

open as a page

In React 19.2, an effect reconnects a chat socket whenever `roomId` changes, but its body also calls the latest `onConnected` prop and reads the current `theme`; listing those in the dependency array makes the socket reconnect every time they change. What does useEffectEvent do here, and what rules govern it?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

useEffectEvent extracts the non-reactive part of an effect into a function that always sees the latest props and state but is not itself a dependency. The effect calls it, so the socket reconnects only when roomId changes.

open as a page