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:
Case 1: Fetching Data
This is the most common example of confusion. The right choice depends on what triggers the fetch.
// ✅ 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.
// ❌ 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).
// ✅ 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.
// ❌ 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>
}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) →
useEffectIf the code runs because the user did something specific → event handler
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.
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} />
}