Session 3 — useEffect, Fetching Data & Error Handling
Every React component goes through three phases during its life. Understanding these phases is the key to knowing when your code runs and why certain bugs happen.
A side effect is anything that reaches outside the component — fetching data, setting a timer, updating the page title, or subscribing to events. React's render function should be pure, so effects live separately in useEffect.
import { useState, useEffect } from 'react' function PageTitle() { const [count, setCount] = useState(0) // useEffect(callback, dependencyArray) useEffect(() => { // This runs AFTER the component renders document.title = `You clicked ${count} times` }, [count]) // ← re-run whenever count changes return ( <button onClick={() => setCount(count + 1)}> Click me </button> ) }
Load data from an API when the component mounts or when a search term changes.
Set up setInterval or setTimeout — and clean them up when the component unmounts.
Update the page title, focus an input, or integrate with non-React libraries.
Subscribe to WebSockets, browser events, or external stores.
The second argument to useEffect — the dependency array — controls when the effect re-runs.
// 1. No dependency array — runs after EVERY render useEffect(() => { console.log('Runs after every render') }) // 2. Empty array [] — runs ONCE after first render (mount) useEffect(() => { console.log('Runs once on mount') // Great for initial data fetching }, []) // 3. Array with values — runs when those values change useEffect(() => { console.log('userId changed to:', userId) }, [userId]) // only re-runs when userId changes
| Dependency Array | When Effect Runs | Use Case |
|---|---|---|
| Omitted | After every render | Sync with every state change (rare) |
| [ ] empty | Once on mount | Fetch data on page load |
| [a, b] | When a or b changes | Re-fetch when search query changes |
Include every variable from the component that the effect uses. Leaving something out can cause stale closure bugs — your effect reads an old value instead of the current one.
The standard React pattern for data fetching uses an async function inside the effect callback — you can't make the callback itself async.
import { useState, useEffect } from 'react' function UserProfile({ userId }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { // Define async function inside effect async function fetchUser() { try { setLoading(true) const res = await fetch(`/api/users/${userId}`) if (!res.ok) throw new Error('User not found') const data = await res.json() setUser(data) } catch (err) { setError(err.message) } finally { setLoading(false) } } fetchUser() // call it immediately }, [userId]) // re-fetch when userId changes if (loading) return <p>Loading...</p> if (error) return <p>Error: {error}</p> return <h2>Hello, {user.name}!</h2> }
An async function returns a Promise. React expects useEffect's callback to return either nothing or a cleanup function — not a Promise. So always define the async function inside and call it.
Axios is a popular HTTP library that simplifies fetching — it parses JSON automatically, throws on non-2xx responses, and has built-in cancellation support.
import { useState, useEffect } from 'react' import axios from 'axios' function UserProfile({ userId }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { // Axios uses CancelToken for cleanup const controller = new AbortController() async function fetchUser() { try { setLoading(true) // axios.get returns { data, status, headers, ... } // No need to call .json() — axios does it automatically // No need to check response.ok — axios throws on 4xx/5xx const { data } = await axios.get(`/api/users/${userId}`, { signal: controller.signal // ties request to AbortController }) setUser(data) } catch (err) { if (axios.isCancel(err)) return // component unmounted — ignore setError(err.response?.data?.message ?? err.message) } finally { setLoading(false) } } fetchUser() return () => controller.abort() // cancel if userId changes or unmounts }, [userId]) if (loading) return <p>Loading...</p> if (error) return <p>Error: {error}</p> return <h2>Hello, {user.name}!</h2> }
| fetch (built-in) | axios (library) | |
|---|---|---|
| JSON parsing | Manual — res.json() |
Automatic — result is in data |
| Error on 4xx/5xx | No — must check res.ok |
Yes — throws automatically |
| Request cancellation | AbortController |
AbortController or CancelToken |
| Install needed? | No — built into browsers | Yes — npm install axios |
A great user experience shows what's happening. Always handle three states: loading, error, and success.
Assume data isn't ready yet. Show a spinner or skeleton screen while waiting.
Network can fail. The API can return errors. Always wrap your fetch in try/catch.
Use finally so loading is always turned off — whether the fetch succeeded or failed.
Return different JSX for loading, error, and the happy path.
Click fetch to simulate a 1.5s API call:
fetch() does not throw on HTTP error codes (404, 500). You must manually check response.ok or response.status and throw an error yourself if needed.
If your effect starts something ongoing — like a timer or event listener — you must stop it when the component unmounts. Return a cleanup function from your effect.
A memory leak happens when your code holds onto resources (memory, connections, timers) that it no longer needs and never releases. Over time those resources pile up — the browser gets slower, crashes, or starts behaving in unexpected ways.
In React, the most common cause is an effect that keeps running after the component it belongs to has been removed from the screen.
<UserProfile> mounts and starts fetching data.setUser(data) — but the component is gone.useEffect(() => { async function fetchUser() { const res = await fetch(`/api/users/${userId}`) const data = await res.json() setUser(data) // ⚠️ might run after the component unmounted! } fetchUser() // No return → no cleanup → fetch keeps going even after unmount }, [userId])
useEffect(() => { const controller = new AbortController() // create a cancel handle async function fetchUser() { try { const res = await fetch(`/api/users/${userId}`, { signal: controller.signal // link request to the handle }) const data = await res.json() setUser(data) // only runs if the component is still mounted } catch (err) { if (err.name === 'AbortError') return // cancelled — not a real error setError(err.message) } } fetchUser() // React calls this when the component unmounts OR before re-running the effect return () => controller.abort() // ✅ request is cancelled, no stale setState }, [userId])
A setInterval keeps firing after unmount, causing stale state updates on every tick.
return () => clearInterval(timer)
Without removal, the same handler is registered on every render — it stacks up silently.
return () => window.removeEventListener('resize', fn)
An in-flight request completes after unmount and tries to update state that no longer exists.
return () => controller.abort()
An open socket keeps receiving messages and calling setState even though the UI is gone.
return () => socket.close()
Think of useEffect like renting a car. When you return the car (unmount), you hand back the keys — you don't keep driving it. The cleanup function is handing back the keys: cancel the fetch, clear the timer, remove the listener. If you forget, someone else pays for the damage.
Let's review the key concepts from today's session.
Runs after render. Used for data fetching, timers, DOM updates, and subscriptions.
Controls when the effect re-runs: omit for always, [] for once, [x] when x changes.
Always handle loading, error, and success in components that fetch data.
Return a cleanup function from useEffect to stop timers and subscriptions.
[] tell useEffect?fetch() throw an error when the server returns 404?In Session 4, we dive deep into React Router — SPAs, route setup, Link & NavLink, URL parameters, useNavigate, and nested layouts.