β»οΈ Effects and Component Lifecycle
Components render pure UI from props and state β but real apps also need to fetch data, subscribe to events, and set timers. Those are side effects, and useEffect is how you run them safely. This lesson demystifies the component lifecycle and the one hook that trips up almost every React beginner.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Describe the three lifecycle phases β mount, update, unmount β and where effects fit
- Use
useEffectwith the right dependency array to control when it runs - Write cleanup functions that prevent memory leaks from listeners, timers, and requests
- Fetch data safely with
AbortController, handling loading, error, and cancellation - Recognize and fix the classic bugs: stale closures, infinite loops, and unstable object dependencies
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a debounced live-search component that fetches results and cancels stale requests.
In This Lesson
The Component Lifecycle
Every component moves through three phases. It is mounted (added to the screen for the first time), then possibly updated many times as its props or state change, and finally unmounted (removed from the screen).
Rendering itself must be pure: given the same props and state, a component returns the same JSX and touches nothing outside itself. But apps need to reach outside β call an API, add a scroll listener, start a countdown. Those interactions with the outside world are side effects, and they must happen after render, not during it.
π Key Term: Side Effect
Any work that reaches beyond returning JSX β fetching data, subscribing to events, setting timers, or manually touching the DOM or document.title. React gives you useEffect to run these safely at the right moment in the lifecycle.
If you've seen old React class components, effects replace the trio of lifecycle methods below. One hook now covers all three jobs:
| Old class method | Lifecycle moment | useEffect equivalent |
|---|---|---|
componentDidMount | After first render | useEffect(fn, []) |
componentDidUpdate | After a dependency changes | useEffect(fn, [dep]) |
componentWillUnmount | Before removal | the return function inside the effect |
Meet useEffect
useEffect takes two arguments: a setup function and an optional dependency array. React runs the setup function after the component renders and the DOM is painted.
import { useEffect } from 'react';
useEffect(() => {
// setup: runs after render
console.log('Component rendered');
return () => {
// cleanup: runs before the next effect and before unmount
console.log('Cleaning up');
};
}, [/* dependencies */]);
The best mental model is synchronization, not lifecycle events. An effect keeps something outside React (a subscription, the document title, a network request) in sync with your component's current props and state. When the relevant state changes, React re-synchronizes by cleaning up the old effect and running a fresh one.
π‘ A simple first effect. Updating the browser tab's title is a classic side effect β it touchesdocument, which lives outside React:function PageTitle({ unread }) { useEffect(() => { document.title = unread > 0 ? `(${unread}) Inbox` : 'Inbox'; }, [unread]); // re-run only when the unread count changes return <h1>Inbox</h1>; }
The Dependency Array
The second argument is the single most important part of useEffect. It tells React when to re-run the effect by listing the reactive values the effect depends on.
| Dependency array | When the effect runs | Typical use |
|---|---|---|
[] (empty) | Once, right after mount | One-time setup: initial fetch, subscribe to an event |
[userId] | After mount, then whenever userId changes | Refetch when the viewed user changes |
| omitted entirely | After every single render | Rare β usually a bug; use with care |
β οΈ The golden rule of dependencies
Every reactive value your effect reads β props, state, or functions defined in the component β must appear in the dependency array. Leaving one out doesn't "optimize" the effect; it hides a bug where the effect works with stale, outdated values. Install eslint-plugin-react-hooks and let it enforce this for you.
Cleanup Functions
If your effect sets something up that persists β an event listener, a timer, a WebSocket β you must tear it down. The function you return from the effect is the cleanup. React calls it before running the effect again and when the component unmounts.
π The Campsite Rule
"Leave no trace." Whatever your effect brought into the world β a listener, a subscription, a running timer β the cleanup packs it back up before you move on. Skip cleanup and you leak memory and get ghost handlers firing on components that no longer exist.
Here's a component that tracks the window width. Without the cleanup, every time it remounts you'd stack another listener that never goes away:
import { useState, useEffect } from 'react';
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
// Cleanup: remove the exact listener we added.
return () => window.removeEventListener('resize', handleResize);
}, []); // set up once on mount, tear down on unmount
return <p>Window width: {width}px</p>;
}
π‘ What needs cleanup?
- Event listeners β
removeEventListener - Timers β
clearTimeout/clearInterval - Subscriptions β call the returned
unsubscribe - WebSockets β
socket.close() - In-flight fetches β
controller.abort()(next section)
Data Fetching Done Right
Fetching in an effect is common, but naΓ―ve versions have a subtle bug: race conditions. If userId changes quickly, an older, slower request can resolve after a newer one and overwrite it with stale data. The fix is to cancel the previous request in the cleanup with an AbortController.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [status, setStatus] = useState('loading'); // 'loading' | 'error' | 'ready'
useEffect(() => {
const controller = new AbortController();
setStatus('loading');
async function load() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setUser(data);
setStatus('ready');
} catch (err) {
// Ignore the "we cancelled it" error; surface real ones.
if (err.name !== 'AbortError') setStatus('error');
}
}
load();
// Cleanup cancels the request if userId changes or we unmount.
return () => controller.abort();
}, [userId]);
if (status === 'loading') return <p>Loadingβ¦</p>;
if (status === 'error') return <p>Could not load user.</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
β Why this is robust
When userId changes, React runs the cleanup first (controller.abort()) so the stale request can never win the race. Only the newest request's result reaches state.
β οΈ In real apps, don't hand-roll fetching
This pattern is worth understanding, but production code usually delegates fetching to a library like TanStack Query or SWR, which handle caching, retries, deduplication, and background refresh for you. Use raw useEffect fetching for learning and simple one-offs.
Three Classic Mistakes
1. Missing dependency β stale closure
Leaving a used value out of the array freezes it at the value from the render when the effect last ran.
// β Wrong: reads userId but omits it β always uses the first userId.
useEffect(() => {
fetchResults(query, userId).then(setResults);
}, [query]);
// β
Right: every value the effect reads is listed.
useEffect(() => {
fetchResults(query, userId).then(setResults);
}, [query, userId]);
2. Updating a dependency inside the effect β infinite loop
If the effect sets state that it also depends on, it re-runs forever.
// β Infinite loop: count changes β effect runs β count changes β β¦
useEffect(() => {
setCount(count + 1);
}, [count]);
// β
If you truly need to react to something else, depend on THAT,
// and use the updater form so you don't depend on count at all.
useEffect(() => {
setCount((c) => c + 1);
}, [someTrigger]);
3. Object/array dependency β runs every render
React compares dependencies by reference. An object or array literal is a brand-new reference every render, so the effect never gets to skip.
// β `options` is a new object each render β effect runs every time.
function Card({ user }) {
const options = { showDetails: true };
useEffect(() => {
fetchDetails(user.id, options);
}, [user.id, options]);
}
// β
Stabilize the reference with useMemo (or move it outside the component).
function Card({ user }) {
const options = useMemo(() => ({ showDetails: true }), []);
useEffect(() => {
fetchDetails(user.id, options);
}, [user.id, options]);
}
π‘ Ask before reaching for an effect. Many things beginners put in effects don't belong there. Transforming data for rendering? Do it during render. Responding to a user click? Put it in the event handler. Effects are for synchronizing with external systems β if there's no external system, you probably don't need one.
Extracting Custom Hooks
When effect logic repeats across components, lift it into a custom hook β a function whose name starts with use and that calls other hooks. This is React's superpower for reusing stateful logic. Here's a reusable data-fetching hook built from the pattern above:
// useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [status, setStatus] = useState('loading');
useEffect(() => {
const controller = new AbortController();
setStatus('loading');
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => {
setData(json);
setStatus('ready');
})
.catch((err) => {
if (err.name !== 'AbortError') setStatus('error');
});
return () => controller.abort();
}, [url]);
return { data, status };
}
Now any component fetches with a single readable line, and the tricky cleanup lives in exactly one place:
function Posts() {
const { data: posts, status } = useFetch('/api/posts');
if (status === 'loading') return <p>Loadingβ¦</p>;
if (status === 'error') return <p>Failed to load.</p>;
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
Hands-on Exercise
ποΈ Build a Debounced Live Search
Objective: Create a search box that waits until the user stops typing, then fetches matching results β cancelling any stale request. This combines timers, cleanup, and dependency arrays.
Instructions:
- Hold the raw input in
querystate and a delayed copy indebouncedQuerystate. - In one effect, start a 400ms
setTimeoutthat copiesqueryintodebouncedQuery; clear the timer in the cleanup so fast typing keeps resetting it. - In a second effect keyed on
debouncedQuery, fetchhttps://jsonplaceholder.typicode.com/posts?q=β¦using anAbortController. - Render the results, plus "Type to searchβ¦" when the query is empty.
π‘ Hint
Two effects, each with its own dependency array, keep the concerns clean: one debounces (depends on query), the other fetches (depends on debouncedQuery). Don't try to do both in a single effect.
β Solution
import { useState, useEffect } from 'react';
function LiveSearch() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState([]);
// Effect 1 β debounce the input.
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 400);
return () => clearTimeout(timer);
}, [query]);
// Effect 2 β fetch when the debounced value settles.
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
return;
}
const controller = new AbortController();
fetch(
`https://jsonplaceholder.typicode.com/posts?q=${debouncedQuery}`,
{ signal: controller.signal }
)
.then((res) => res.json())
.then(setResults)
.catch((err) => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search postsβ¦"
/>
{!query && <p>Type to searchβ¦</p>}
<ul>
{results.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
π― Quick Quiz
Question 1: What does an empty dependency array [] tell useEffect to do?
Question 2: Why does fetching in an effect use an AbortController in the cleanup?
Question 3: An effect reads query and userId but its array is [query]. What's the likely bug?
Summary & Quiz
π Key Takeaways
- Components live through mount β update β unmount; effects run side effects after render.
- Think of
useEffectas synchronizing with an external system, not as lifecycle callbacks. - The dependency array controls timing; list every reactive value the effect reads.
- Always clean up listeners, timers, subscriptions, and requests to avoid leaks and races.
- Beware the big three: stale closures, infinite loops, unstable object deps β and lift repeated effect logic into custom hooks.
π Further Reading
- React docs β Synchronizing with Effects
- React docs β You Might Not Need an Effect
- React reference β useEffect
π What's Next?
You've now handled inputs and events in passing β next we go deep on Form Handling in React: controlled vs. uncontrolled components, validation strategies, and the modern libraries that make complex forms painless.
π Effect mastery unlocked!
You can now safely connect your components to APIs, events, and timers β and clean up after yourself.