π Component Lifecycle and Effects
Components are born, they update, and eventually they disappear. Anything that reaches outside React during that life β fetching data, starting a timer, subscribing to an event β is a side effect, and the useEffect hook is how you run those effects at the right moment and clean them up when they're no longer needed.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Describe the three phases of a component's lifecycle: mount, update, unmount
- Identify side effects and explain why they belong in
useEffect - Control effect timing with the dependency array (none,
[], and[deps]) - Write cleanup functions to remove listeners, clear timers, and cancel work
- Avoid the classic traps: infinite loops, missing dependencies, stale closures, and race conditions
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a start/pause interval timer with correct effect cleanup.
In This Lesson
The Component Lifecycle
Every component passes through three phases. Understanding them tells you when your code runs relative to the screen updating.
- Mount β the component is created and inserted into the page for the first time.
- Update β its props or state change, so it re-renders (this can happen many times).
- Unmount β it is removed from the page, and any resources it set up must be released.
Class components used to expose named methods for these moments β componentDidMount, componentDidUpdate, componentWillUnmount. Function components unify all three into one hook: useEffect. Rather than thinking "run this on mount," the modern mindset is "keep this effect synchronized with these values."
π Key Term β Synchronization
The modern way to reason about effects: an effect keeps something outside React (a subscription, the document title, a network request) in sync with your component's current props and state. React runs and re-runs it as needed to maintain that sync.
What Is a Side Effect?
Rendering should be pure: given the same props and state, a component should return the same JSX and touch nothing else. A side effect is any operation that reaches outside that pure calculation. Common examples:
- Fetching data from an API
- Setting up subscriptions or event listeners
- Starting timers (
setTimeout,setInterval) - Reading or writing browser APIs (
localStorage, geolocation, the document title) - Manually interacting with a non-React widget
π‘ The restaurant analogy: Your visit's main purpose is to eat (render the UI). But to make that happen you place an order (a side effect) β an action directed outside your table. And when you leave, you settle the bill (cleanup). Effects are the orders your component places with the outside world.
Side effects don't belong in the render body because renders can happen often and at unpredictable times. useEffect gives them a controlled home that runs after the screen updates.
The useEffect Hook
useEffect takes two arguments: a function containing your effect, and an optional dependency array that controls when it runs. The effect can optionally return a cleanup function.
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// Effect: runs after the render is painted to the screen
console.log('synced');
// Optional cleanup: runs before the next effect and on unmount
return () => {
console.log('cleaning up');
};
}, [/* dependencies */]);
return <div>Hello</div>;
}
Crucially, effects run after React has committed the render and the browser has painted. That ordering keeps the UI responsive β your effect never blocks the screen from updating.
The Dependency Array
The second argument is the control dial for how often your effect runs. There are exactly three cases:
| Dependency array | When the effect runs | Class-era equivalent |
|---|---|---|
| Omitted | After every render | componentDidMount + componentDidUpdate |
[] (empty) | Once, after the first render | componentDidMount |
[a, b] | After the first render, then whenever a or b changes | componentDidMount + conditional componentDidUpdate |
React compares each dependency to its previous value with Object.is and re-runs the effect only if one changed. The golden rule: every value from component scope that the effect uses must appear in the array. Leaving one out causes the effect to read a stale value.
function ProductList({ category, sortBy, page }) {
const [products, setProducts] = useState([]);
useEffect(() => {
// Re-runs whenever any of the three inputs change
fetchProducts(category, sortBy, page).then(setProducts);
}, [category, sortBy, page]);
return products.map(p => <ProductCard key={p.id} product={p} />);
}
π‘ Let the linter help
The eslint-plugin-react-hooks rule exhaustive-deps flags missing dependencies for you. Trust it β most "why is my effect stale?" bugs are a dependency you forgot to list.
Cleanup Functions
If your effect sets up something ongoing β an event listener, a timer, a subscription β you must tear it down. Return a cleanup function from the effect. React runs it before the effect runs again, and once more when the component unmounts.
function WindowSize() {
const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });
useEffect(() => {
const handleResize = () =>
setSize({ w: window.innerWidth, h: window.innerHeight });
window.addEventListener('resize', handleResize);
// Cleanup: remove the listener so it doesn't pile up or fire after unmount
return () => window.removeEventListener('resize', handleResize);
}, []); // set up once, tear down on unmount
return <p>{size.w} Γ {size.h}</p>;
}
π‘ The subscription analogy: Subscribing to a newspaper when you move into an apartment means cancelling it when you move out. Cleanup is telling the delivery to stop β without it, papers keep arriving at an address no one lives at, wasting resources and causing errors.
Common things to clean up: event listeners (removeEventListener), timers (clearTimeout/clearInterval), WebSocket or observable subscriptions, and in-flight requests. The stale closure trap makes cleanup especially important with timers:
function Ticker() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// Functional update avoids capturing a stale `count`
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id); // stop the interval on unmount
}, []); // empty deps is fine because we used the functional update
return <p>{count}</p>;
}
Data Fetching & Race Conditions
Fetching in an effect is a classic use case β but it hides a subtle bug. If a dependency changes quickly (say the user switches profiles), an earlier request can resolve after a later one, overwriting fresh data with stale data. This is a race condition. The fix is a cleanup flag (or an AbortController) that ignores results from a superseded effect.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false; // becomes true when this effect is superseded
const controller = new AbortController();
async function load() {
try {
setLoading(true);
const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
if (!res.ok) throw new Error('Failed to load user');
const data = await res.json();
if (!ignore) setUser(data); // only the latest effect wins
} catch (err) {
if (!ignore && err.name !== 'AbortError') setError(err.message);
} finally {
if (!ignore) setLoading(false);
}
}
load();
return () => {
ignore = true; // ignore this request's result
controller.abort(); // and cancel it if still in flight
};
}, [userId]); // re-fetch whenever userId changes
if (loading) return <p>Loadingβ¦</p>;
if (error) return <p>Error: {error}</p>;
return <h2>{user.name}</h2>;
}
β οΈ In real apps, prefer a data library
Hand-written fetch effects work, but managing loading, errors, caching, retries, and races by hand gets old fast. For production, reach for TanStack Query or SWR β they handle all of this and dedupe requests. Use raw useEffect fetching to learn the mechanics; use a library to ship.
Hands-on Exercise
ποΈ Build a Start/Pause Timer
Objective: Practice effect setup and cleanup with a controllable interval.
Requirements:
- Display a number of seconds, starting at 0.
- A Start/Pause button toggles a
runningboolean. - While
running, increment the count every second; while paused, stop. - A Reset button sets the count back to 0.
- The interval must be cleaned up whenever it pauses or the component unmounts β no leaks.
π‘ Hint
Put running in the dependency array. When it's true, start an interval in the effect and return a cleanup that clears it. When running flips to false, React runs the cleanup automatically. Use the functional update form inside setInterval so you don't need count as a dependency.
β Solution
import { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
const [running, setRunning] = useState(false);
useEffect(() => {
if (!running) return; // paused: no interval, nothing to clean up
const id = setInterval(() => {
setSeconds(prev => prev + 1); // functional update β no stale count
}, 1000);
return () => clearInterval(id); // runs on pause and on unmount
}, [running]);
return (
<div>
<p>{seconds}s</p>
<button onClick={() => setRunning(r => !r)}>
{running ? 'Pause' : 'Start'}
</button>
<button onClick={() => { setRunning(false); setSeconds(0); }}>
Reset
</button>
</div>
);
}
The key insight: making the effect depend on running lets React start and stop the interval for you through the normal setup/cleanup cycle.
π― Quick Quiz
Question 1: An effect with an empty dependency array [] runsβ¦
Question 2: This effect causes an infinite loop. Why? useEffect(() => { setCount(count + 1); }, [count]);
Question 3: Why return a cleanup function from an effect that adds a resize listener?
Common Mistakes
β οΈ The usual suspects
- Missing dependencies. Omitting a value the effect reads makes it use a stale version. List every one; let
exhaustive-depscatch you. - Infinite loops. An effect that sets a state value listed in its own dependency array re-triggers itself forever.
- Stale closures in timers. Using
countdirectly insidesetIntervalcaptures the mount-time value; use the functional updatersetCount(prev => ...). - Forgotten cleanup. Listeners, intervals, and sockets that are never torn down cause leaks and duplicate handlers.
- Race conditions. Overlapping fetches can land out of order; guard with an
ignoreflag orAbortController.
β You might not need an effect
Effects are for synchronizing with external systems. If you're only transforming data for rendering or responding to a user event, you often don't need an effect at all β compute the value during render, or do the work in the event handler. Overusing effects is a common source of bugs.
Summary & Quiz
π Key Takeaways
- Components live through mount β update β unmount;
useEffecthandles all three in function components. - A side effect reaches outside pure rendering β fetching, timers, listeners, browser APIs.
- The dependency array controls timing: omitted (every render),
[](once),[deps](when deps change). - Return a cleanup function to remove listeners, clear timers, and cancel work.
- Guard async effects against race conditions, and reach for a data library to ship real fetching.
π Further Reading
- React Docs β useEffect Reference
- React Docs β Synchronizing with Effects
- React Docs β You Might Not Need an Effect
π What's Next?
Your components can now remember data and synchronize with the outside world. Next we focus on how users drive them: React's Event Handling System β synthetic events, passing arguments to handlers, and the patterns that keep interactive UIs clean.
π In sync!
Effects connected, cleanup handled. On to handling user events.