ReactEffects vs Event Handlers

Effects vs Event Handlers

One of the most important conceptual distinctions in React is knowing whether code belongs in a useEffect or in an event handler. Putting code in the wrong place leads to subtle bugs, unnecessary network requests, and effects that run at the wrong time.

The Core Distinction

useEffect

Event handler

When it runs

After render, when deps change (synchronization)

When the user does something (interaction)

Triggered by

React (after rendering)

User action (click, submit, keypress)

Purpose

Sync with external system

Respond to a specific user intent

Example

Fetch data when userId changes

Fetch data when user clicks "Load"

The diagnostic question to ask yourself is:

Note
**"Should this code run because the component rendered, or because something happened?"** If the answer is 'because it rendered' — use an effect. If the answer is 'because the user did something' — use an event handler.
Case 1: Fetching Data

This is the most common example of confusion. The right choice depends on what triggers the fetch.

JSX
// ✅ Fetch on mount/when dep changes → effect is correct.
// The component needs to display a user — it should fetch as soon as it has a userId.
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)

  useEffect(() => {
    const controller = new AbortController()
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setUser)
    return () => controller.abort()
  }, [userId]) // correct: synchronize 'user' state with 'userId' prop

  return <div>{user?.name}</div>
}

// ✅ Fetch on button click → event handler is correct.
// The user is explicitly asking to load — put the fetch in the handler.
function SearchPage() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  async function handleSearch() {
    // NOT useEffect — this is in direct response to a user click
    const data = await fetch(`/api/search?q=${query}`).then(r => r.json())
    setResults(data)
  }

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <button onClick={handleSearch}>Search</button>
      <ResultsList items={results} />
    </div>
  )
}
Case 2: Showing a Toast Notification

A common mistake: using useEffect to watch for a success state and show a toast. This causes problems — the toast fires any time the component re-renders with success === true, including when the component unmounts and remounts.

JSX
// ❌ Wrong: effect watching state to show a toast
function OrderForm() {
  const [success, setSuccess] = useState(false)

  // BUG: fires any time the component re-mounts with success=true,
  // or if something causes a re-render while success is still true.
  useEffect(() => {
    if (success) {
      showToast('Order placed successfully!')
    }
  }, [success])

  async function handleSubmit() {
    await placeOrder()
    setSuccess(true)
  }

  return <form onSubmit={handleSubmit}>...</form>
}

// ✅ Correct: show the toast directly in the event handler
function OrderForm() {
  async function handleSubmit() {
    await placeOrder()
    // The toast is a direct response to the submit action — it belongs here.
    showToast('Order placed successfully!')
  }

  return <form onSubmit={handleSubmit}>...</form>
}

The toast fires in response to a specific user action (submitting the order), not because of a render. It should live in the event handler, not inside an effect watching a flag.

Case 3: Sending Analytics Events

Analytics can go in either place, but the choice matters. A page-view event should fire when the page renders (effect). A button-click event should fire when the button is clicked (handler).

JSX
// ✅ Page view → effect (fires because the page rendered)
function ProductPage({ productId }) {
  useEffect(() => {
    analytics.track('product_viewed', { productId })
  }, [productId])

  return <div>...</div>
}

// ✅ Button click → event handler (fires because the user clicked)
function AddToCartButton({ productId }) {
  function handleClick() {
    analytics.track('add_to_cart', { productId }) // direct response to click
    addToCart(productId)
  }

  return <button onClick={handleClick}>Add to Cart</button>
}
Case 4: POST Request on Form Submit

Submitting a form sends data to a server. This is not synchronization with an external system — it is a direct response to a user action. Never wrap a POST request in useEffect.

JSX
// ❌ Wrong: watching form state to trigger a POST
function ContactForm() {
  const [formData, setFormData] = useState(null)

  // Fires whenever formData changes — even on accidental re-renders
  useEffect(() => {
    if (formData) {
      fetch('/api/contact', {
        method: 'POST',
        body: JSON.stringify(formData),
      })
    }
  }, [formData])

  function handleSubmit(e) {
    e.preventDefault()
    setFormData({ name: e.target.name.value, message: e.target.message.value })
  }

  return <form onSubmit={handleSubmit}>...</form>
}

// ✅ Correct: POST directly in the submit handler
function ContactForm() {
  async function handleSubmit(e) {
    e.preventDefault()
    const formData = { name: e.target.name.value, message: e.target.message.value }

    // The request is a direct consequence of the user submitting the form.
    await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify(formData),
    })
  }

  return <form onSubmit={handleSubmit}>...</form>
}
Warning
The effect-based pattern above is particularly dangerous because setting `formData` to a non-null value causes the effect to run. In React Strict Mode, the effect runs twice, which means **two POST requests** are sent for a single form submission. The event handler version sends exactly one.
When Effects Look Right but Are Wrong

A useful test: can this effect produce a visible change without the user doing anything? If yes, it might be correct. If the change should only happen in response to a user action, move it to the event handler.

  • Fetching initial data → effect (renders immediately with data)

  • Re-fetching when a filter changes → effect (filter change is the trigger, not a user button click)

  • Submitting a form → event handler (only on explicit submit)

  • Showing a success message after submit → event handler (in the same handler as the submit)

  • Navigating after login → event handler (in the login handler, not watching isLoggedIn)

  • Subscribing to a WebSocket → effect (start on mount, stop on unmount)

The Causality Rule

Think about causality:

  • If the code runs because the component is in a certain state (rendered, mounted, prop changed) → useEffect

  • If the code runs because the user did something specific → event handler

Tip
When in doubt: can the user trigger this behavior multiple times by clicking? If yes, put it in the click handler. Can it happen without any user interaction at all (e.g., on navigation, on mount)? If yes, put it in an effect.
Mixing Both

Sometimes you need both: an event handler triggers a state change, and an effect reacts to that state change to synchronize an external system. This is fine and intentional — they have different responsibilities.

JSX
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([])

  // Effect: synchronize the connection — whenever roomId changes,
  // disconnect from the old room and connect to the new one.
  useEffect(() => {
    const socket = connectToRoom(roomId)
    socket.on('message', msg => setMessages(prev => [...prev, msg]))
    return () => socket.disconnect()
  }, [roomId])

  // Event handler: send a message in response to user action.
  async function handleSend(text) {
    await sendMessage(roomId, text) // direct response to button click
  }

  return <Chat messages={messages} onSend={handleSend} />
}