04-03 left a promise on the table: the correct mental model for what classes used to call the "lifecycle" isn't a sequence of moments (componentDidMount, componentDidUpdate, componentWillUnmount), but rather synchronizing with an external system. This lesson delivers on that promise. useEffect is the tool that connects a React component with everything that lives outside React: timers, browser events, the tab title, a network connection, a server. But before learning how to use it, you have to learn how not to use it, because useEffect is the most misused hook in the ecosystem: people stuff it with logic that belongs in the render or in an event handler, and the result is extra renders, flicker, infinite loops, and bugs that are impossible to reproduce. Here you'll see its full anatomy, when it actually runs, why StrictMode calls it twice and why that's a good thing, four legitimate cases in CicloUrbano — including data loading with race conditions — and the antipatterns you need to recognize on sight.
Contents
- What is an effect, and what isn't
- Most code doesn't need
useEffect - Anatomy: effect function, cleanup, and dependencies
- The three forms of the dependency array
- The real execution cycle
StrictMode, the double execution, and the cleanup test- Case 1: an availability timer
- Case 2: listening to a browser event
- Case 3: loading data from an API and the race condition
- Case 4: synchronizing the document title
- The dependency array in depth
- Infinite loops: real causes and real fixes
- Antipatterns you need to recognize
useLayoutEffectand modern data loading
- What is an effect, and what isn't
Let's start with a precise definition, because the word "effect" gets misused constantly.
An effect is a piece of code that synchronizes the component with a system external to React, and that React runs after painting the screen, not during render.
Both parts matter. "External system" means anything React doesn't control:
| External system | Example in CicloUrbano |
|---|---|
| Browser timers | setInterval that refreshes a station's free docks |
| DOM APIs outside React's tree | document.title, localStorage, matchMedia |
| Global events | window.addEventListener('online', …) |
| The network | fetch to the bikes endpoint |
| Third-party libraries | An OpenStreetMap map showing the stations |
| Persistent connections | A WebSocket that announces returned bikes |
And now what an effect is not, which is where most of the mistakes are:
| Situation | Is it an effect? | Where it actually belongs |
|---|---|---|
| Filtering the fleet by type to display it | ❌ No | During render, as a derived value |
| Computing the booking's total price | ❌ No | During render, as a derived value |
| Submitting the booking when "Confirm" is pressed | ❌ No | In the onSubmit handler |
| Logging analytics for a click | ❌ No | In the onClick handler |
| Showing a notice when a button is pressed | ❌ No | In the handler |
| Subscribing to a timer while the panel is visible | ✅ Yes | In useEffect |
| Loading the stations when the screen appears | ✅ Yes | In useEffect (or in a router/library) |
The question that separates one case from the other is simple: does this happen because the user did something specific, or because the component is on screen? If it's the former, it goes in a handler. If it's the latter, it's an effect.
- Most code doesn't need
useEffect
useEffectThis deserves its own section because it's the single most valuable piece of advice in the whole lesson. Here are the three cases where people reach for useEffect without needing it.
Transforming data for render
// ❌ BAD: state and an effect for something that's just a computation
function Catalogue({ bikes, chosenType }) {
const [visibleBikes, setVisibleBikes] = useState([]);
useEffect(() => {
setVisibleBikes(
chosenType === 'todos'
? bikes
: bikes.filter((bike) => bike.type === chosenType)
);
}, [bikes, chosenType]);
...
}This code works, but it triggers two renders per change: one with the old list, and another, after the effect, with the new one. For a fraction of a second the screen shows incorrect data. And if you forget a dependency, it shows incorrect data forever.
// ✅ GOOD: it's a derived value, just like in 04-01
function Catalogue({ bikes, chosenType }) {
const visibleBikes =
chosenType === 'todos'
? bikes
: bikes.filter((bike) => bike.type === chosenType);
...
}One render, always consistent, impossible to desynchronize. If the computation were genuinely expensive, the fix isn't an effect — it's useMemo (08-03).
Responding to a user event
// ❌ BAD: the effect has no idea WHY the state changed
useEffect(() => {
if (bookingConfirmed) {
trackAnalytics('booking_confirmed');
}
}, [bookingConfirmed]);
// ✅ GOOD: analytics belongs in the handler that caused the event
function handleConfirm({ bike, hours, total }) {
trackAnalytics('booking_confirmed', { bicicletaId: bike.id, hours, total });
setSelectedBike(null);
}The effect-based version has a serious flaw: if the bookingConfirmed state gets set back to true for some other reason — reloading a saved draft, for instance — the analytics fire again. The handler only runs when someone actually presses the button, which is exactly what we want to measure.
Resetting state when a prop changes
// ❌ BAD: render with stale data, then a correction
useEffect(() => {
setHours(1);
setErrors({});
}, [bike.id]);
// ✅ GOOD: change the component's identity with key (05-01, section 10)
<BookingPanel key={bike.id} bike={bike} />The rule that sums up this section: if you can compute it during render or do it in a handler, it isn't an effect.
- Anatomy: effect function, cleanup, and dependencies
useEffect(() => {
// 1. EFFECT FUNCTION: runs after painting
const connection = createConnection(stationId);
connection.open();
// 2. CLEANUP FUNCTION (optional): undoes what the effect did
return () => {
connection.close();
};
}, [stationId]); // 3. DEPENDENCY ARRAYThe three pieces, one by one:
- The effect function contains whatever needs to happen to start synchronizing. React runs it after applying changes to the DOM and after the browser has painted, so it never blocks the interface.
- The cleanup function is what the effect function returns. It contains whatever's needed to stop synchronizing. React runs it right before re-running the effect, and also when the component unmounts. If your effect creates, opens, subscribes to, or starts something, the cleanup must destroy it, close it, unsubscribe from it, or stop it. It's an almost mechanical symmetry:
| What the effect does | What the cleanup must do |
|---|---|
setInterval / setTimeout |
clearInterval / clearTimeout |
addEventListener |
removeEventListener |
connection.open() |
connection.close() |
fetch(...) |
controller.abort() |
library.init(node) |
library.destroy() |
Changing document.title |
Restoring the previous title, if relevant |
- The dependency array tells React which values the synchronization depends on. If none of them changed compared to the previous render, React skips the effect entirely.
- The three forms of the dependency array
| Form | When the effect runs | When the cleanup runs | Typical use |
|---|---|---|---|
useEffect(fn) — no array |
After every render | Before each new run, and on unmount | Almost never. Usually an oversight |
useEffect(fn, []) — empty array |
Only after the first render | Only on unmount | Global subscriptions that don't depend on anything |
useEffect(fn, [a, b]) — with dependencies |
After the first render, and whenever a or b change |
Before each re-run, and on unmount | The normal case |
Examples of all three, with the same component:
// No array: a new timer gets created on every render. Almost certainly a bug.
useEffect(() => {
console.log('Runs on EVERY render');
});
// Empty array: a global subscription for the component's entire lifetime
useEffect(() => {
function handleConnectionChange() { setIsOnline(navigator.onLine); }
window.addEventListener('online', handleConnectionChange);
window.addEventListener('offline', handleConnectionChange);
return () => {
window.removeEventListener('online', handleConnectionChange);
window.removeEventListener('offline', handleConnectionChange);
};
}, []);
// With dependencies: the synchronization is redone when the station changes
useEffect(() => {
const connection = createAvailabilityConnection(stationId);
connection.open();
return () => connection.close();
}, [stationId]);Dependencies are compared with Object.is, meaning by reference for objects, arrays, and functions. This is the cause of 90% of infinite loops, and we cover it in section 12.
- The real execution cycle
This diagram permanently replaces the lifecycle-methods table:
flowchart TD
A["Render: React calls the component"] --> B["Commit: React applies changes to the DOM"]
B --> C["The browser paints the screen"]
C --> D["React runs the EFFECT"]
D --> E{"New render?"}
E -- "Dependencies do NOT change" --> F["Nothing happens:<br/>the effect is skipped"]
F --> E
E -- "Dependencies DO change" --> G["Runs the previous effect's CLEANUP"]
G --> H["Runs the EFFECT with the new values"]
H --> E
E -- "The component unmounts" --> I["Runs CLEANUP one last time"]
style D fill:#dcfce7
style G fill:#fde68a
style I fill:#fecaca
What to hold on to: cleanup and effect are always paired. A new effect never runs without the previous one having been cleaned up first. That's why the "mount / update / unmount" mental model is misleading: from useEffect's point of view there aren't three distinct moments, only start synchronizing and stop synchronizing, repeated as many times as needed.
Translated to CicloUrbano: if the panel is connected to station est-01 and the user switches to est-02, React closes the connection to est-01 and opens one to est-02. That "change" isn't a special case — it's a stop followed by a start.
StrictMode, the double execution, and the cleanup test
StrictMode, the double execution, and the cleanup testIn 01-03 you saw that <StrictMode> mounts every component twice in development. With effects the behavior is even more striking: React runs the effect, runs its cleanup, and runs the effect again, all during the initial mount.
Console output in development with StrictMode:
Connection opened with est-01
Connection closed with est-01
Connection opened with est-01A lot of people react by removing StrictMode. That's exactly the wrong move. What React is actually doing is putting your effect through a test: if the component unmounts and remounts (which happens constantly in real applications when navigating between routes, as you'll see in 06-01), does everything end up back where it should be?
- If your effect is written correctly, the "effect → cleanup → effect" sequence leaves the system in the same state as a single run. You notice nothing.
- If it's missing the cleanup, the double execution makes it visible: two timers running at once, two subscriptions, two requests. The bug was already there;
StrictModejust surfaces it in development instead of in production.
// ❌ No cleanup: with StrictMode you'll see TWO timers incrementing at once
useEffect(() => {
setInterval(() => setSeconds((previous) => previous + 1), 1000);
}, []);
// ✅ With cleanup: the double execution is indistinguishable from a single one
useEffect(() => {
const id = setInterval(() => setSeconds((previous) => previous + 1), 1000);
return () => clearInterval(id);
}, []);In production, StrictMode doesn't duplicate anything. The golden rule: if removing StrictMode fixes your problem, the problem is still there.
- Case 1: an availability timer
The first legitimate case. StationAvailability checks every ten seconds how many free docks a station has and displays it on screen.
// src/components/StationAvailability.jsx
import { useState, useEffect } from 'react';
import styles from './StationAvailability.module.css';
/**
* Free docks at a station, refreshed periodically.
* Props:
* - station (Station object, required)
* - intervalMs (number, optional, defaults to 10000)
*/
function StationAvailability({ station, intervalMs = 10000 }) {
const [freeDocks, setFreeDocks] = useState(station.docks);
const [lastReading, setLastReading] = useState(null);
useEffect(() => {
// START synchronizing with the browser's timer
const intervalId = setInterval(() => {
const free = checkFreeDocks(station.id);
setFreeDocks(free);
setLastReading(new Date());
}, intervalMs);
// STOP synchronizing
return () => clearInterval(intervalId);
}, [station.id, intervalMs]);
return (
<p className={styles.availability} aria-live="polite">
{station.name}: {freeDocks} of {station.docks} free docks
{lastReading && (
<span className={styles.timestamp}> · {lastReading.toLocaleTimeString('en-GB')}</span>
)}
</p>
);
}
export default StationAvailability;Details that make this effect correct:
- The dependency is
station.id, notstation. Thestationobject might be a fresh reference on every render of the parent even when the data is identical, which would restart the timer constantly. The id is a string: it's compared by value. intervalMsis in the dependencies because the timer depends on it. If the parent changes the frequency, the synchronization has to be redone.setFreeDocksis not in the dependencies: updaters are stable (05-01, section 2).- The cleanup cancels the timer. Without it, switching stations would leave the previous timer running forever, writing into a component that's already showing a different station.
- Case 2: listening to a browser event
CicloUrbano needs to warn the user when the device loses its connection, because bookings can't be confirmed without a network.
// src/components/ConnectionNotice.jsx
import { useState, useEffect } from 'react';
import Notice from './Notice.jsx';
function ConnectionNotice() {
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
useEffect(() => {
function handleOnline() { setIsOnline(true); }
function handleOffline() { setIsOnline(false); }
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (isOnline) return null;
return (
<Notice tone="warning">
You're offline. You can browse the catalogue, but you can't confirm bookings.
</Notice>
);
}
export default ConnectionNotice;Three observations:
- The handler functions are declared inside the effect. If they lived outside, in the component body, they'd be fresh references on every render and the linter would demand you include them in the dependencies, which would restart the subscription for no reason.
removeEventListenerneeds the exact same reference that was passed toaddEventListener. Writingwindow.removeEventListener('online', () => setIsOnline(true))removes nothing, because it's a different function. This mistake leaves listeners piling up and is a classic memory leak.- The initial state uses lazy initialization (
() => navigator.onLine) so it doesn't query the browser API on every render.
- Case 3: loading data from an API and the race condition
The most common case, and the one that causes the most trouble. Let's start with the naive version:
// ⚠️ Works… until the user switches stations quickly
function ActivityPanel({ stationId }) {
const [bikes, setBikes] = useState([]);
useEffect(() => {
fetch(`/api/estaciones/${stationId}/bicicletas`)
.then((response) => response.json())
.then((data) => setBikes(data));
}, [stationId]);
...
}The problem has a name: race condition. If the user selects est-01 and then quickly est-02, two requests get sent. Nothing guarantees they'll arrive in order.
sequenceDiagram
participant U as User
participant C as Component
participant S as Server
U->>C: Selects est-01
C->>S: GET /api/estaciones/est-01/bicicletas
U->>C: Selects est-02 (quickly)
C->>S: GET /api/estaciones/est-02/bicicletas
S-->>C: Response for est-02 (arrives first: smaller payload)
Note over C: setBikes(bikes from est-02) ✅
S-->>C: Response for est-01 (arrives later: was slow)
Note over C: setBikes(bikes from est-01) ❌
Note over U: The screen says "est-02"<br/>but shows the bikes from est-01
There are two solutions, and the idiomatic move is to apply both.
The ignore flag
Use the cleanup to mark the previous response as stale:
useEffect(() => {
let ignore = false;
fetch(`/api/estaciones/${stationId}/bicicletas`)
.then((response) => response.json())
.then((data) => {
if (!ignore) { // only update if this effect is still current
setBikes(data);
}
});
return () => { ignore = true; }; // when stationId changes, this request is invalidated
}, [stationId]);The key is that every run of the effect has its own ignore variable, thanks to JavaScript's closures. When stationId changes, React runs the cleanup for the old effect, which sets its ignore to true. If that response arrives late, it gets discarded.
AbortController
The flag prevents the bug, but the request keeps traveling and consuming bandwidth. AbortController actually cancels it:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/estaciones/${stationId}/bicicletas`, { signal: controller.signal })
.then((response) => response.json())
.then((data) => setBikes(data))
.catch((err) => {
if (err.name !== 'AbortError') { // canceling isn't a real failure
setError(err.message);
}
});
return () => controller.abort();
}, [stationId]);The full version we'll use in CicloUrbano
// src/components/ActivityPanel.jsx
import { useState, useEffect } from 'react';
import BikeList from './BikeList.jsx';
import Notice from './Notice.jsx';
/**
* A station's bikes, loaded from the API.
* Props:
* - stationId (string, required)
*/
function ActivityPanel({ stationId }) {
const [bikes, setBikes] = useState([]);
const [loadState, setLoadState] = useState('idle');
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
let ignore = false;
async function load() {
setLoadState('loading');
setError(null);
try {
const response = await fetch(
`/api/estaciones/${stationId}/bicicletas`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error(`The server responded ${response.status}`);
}
const data = await response.json();
if (!ignore) {
setBikes(data);
setLoadState('success');
}
} catch (err) {
if (err.name === 'AbortError') return; // expected cancellation
if (!ignore) {
setError(err.message);
setLoadState('error');
}
}
}
load();
return () => {
ignore = true;
controller.abort();
};
}, [stationId]);
if (loadState === 'loading') return <p aria-live="polite">Loading bikes…</p>;
if (loadState === 'error') return <Notice tone="error">Couldn't load the station: {error}</Notice>;
return <BikeList bikes={bikes} />;
}
export default ActivityPanel;Two technical details that often get overlooked:
- The effect function can't be
async. Anasyncfunction returns a promise, and React expects the returned value to be the cleanup function. That's whyload()is declared inside and called; neveruseEffect(async () => {…}, []). fetchdoesn't throw on a 404 or a 500. It only throws when the network itself fails. You have to checkresponse.okby hand, as the example does.- The state structure follows principle 3 from 05-01:
loadStateis a string with mutually exclusive values, not three booleans that could contradict each other.
- Case 4: synchronizing the document title
The smallest case, and the most instructive one, because it teaches the key word: synchronize.
// src/App.jsx (excerpt)
const available = bikes.filter((bike) => bike.status === 'disponible').length;
useEffect(() => {
document.title = `CicloUrbano · ${available} bikes available`;
}, [available]);We're not "running code on mount": we're declaring that the tab title must always reflect the number of available bikes. React takes care of redoing it whenever that number changes. That shift in perspective — from "when does this run" to "what needs to stay true" — is the mental model promised back in 04-03.
If you wanted to be strict about it, the cleanup would restore the original title on unmount:
useEffect(() => {
const previousTitle = document.title;
document.title = `CicloUrbano · ${available} bikes available`;
return () => { document.title = previousTitle; };
}, [available]);
- The dependency array in depth
What belongs in the array
Every reactive value used inside the effect. A value is reactive if it's declared inside the component and can change between renders: props, state, and any variable or function derived from them.
| Value used in the effect | Does it go in the dependencies? | Why |
|---|---|---|
A prop (stationId) |
✅ Yes | Can change on every render |
A state value (bookingHours) |
✅ Yes | Can change |
A derived constant (const url = ...id) |
✅ Yes | Depends on reactive values |
useState's updater |
❌ No | React guarantees it's stable |
useReducer's dispatch |
❌ No | Stable (05-05) |
A ref (myRef) |
❌ No | The object is stable (05-03) |
| A constant outside the component | ❌ No | Not reactive: never changes |
An import (bikes from domain.js) |
❌ No | Lives outside the component |
Why lying about it breaks things
It's tempting to drop a dependency to make the effect "stop firing." It never works: the effect will keep using the value from the render it last ran in — that is, a frozen value (the snapshot from 05-01).
// ❌ A lie: the effect subscribes to the first station and never changes again
useEffect(() => {
const connection = createAvailabilityConnection(stationId);
connection.open();
return () => connection.close();
}, []); // the linter warns: 'stationId' is missingThe user switches stations, the interface shows est-02, and the data that arrives is still est-01's. It's exactly the kind of bug nobody reproduces and that shows up in production.
The linter
eslint-plugin-react-hooks (04-04) includes the exhaustive-deps rule, which compares what's inside the effect against the array and warns about what's missing:
React Hook useEffect has a missing dependency: 'stationId'.
Either include it or remove the dependency array. react-hooks/exhaustive-depsTreat it as an error, not a suggestion. And never silence it with // eslint-disable-next-line react-hooks/exhaustive-deps: if the warning bothers you, the fix isn't muting it — it's changing the code so the dependency stops being a problem.
- Infinite loops: real causes and real fixes
Symptom: the tab freezes, the console spits out thousands of lines, or React throws "Maximum update depth exceeded." It's always the same structure: the effect changes something that's in its own dependencies.
Cause 1: an object or array recreated on every render
// ❌ LOOP: 'filters' is a NEW object on every render
function Catalogue({ type }) {
const filters = { type, status: 'disponible' };
useEffect(() => {
searchBikes(filters).then(setResults);
}, [filters]); // Object.is(newObject, oldObject) === false, ALWAYS
}Every render creates a different object; React sees a changed dependency, runs the effect, the effect updates state, that triggers a render, which creates another object… Solutions, in order of preference:
// ✅ 1. Move the object INSIDE the effect and depend on its primitive values
useEffect(() => {
const filters = { type, status: 'disponible' };
searchBikes(filters).then(setResults);
}, [type]);
// ✅ 2. Depend on the primitives directly
useEffect(() => {
searchBikes({ type, status }).then(setResults);
}, [type, status]);
// ✅ 3. If the object comes from outside and you can't change it, extract what you need
const { type, status } = filters;
useEffect(() => {
searchBikes({ type, status }).then(setResults);
}, [type, status]);Cause 2: a function recreated on every render
// ❌ LOOP: 'load' is a new function on every render
function StationPanel({ stationId }) {
function load() {
fetch(`/api/estaciones/${stationId}`).then((r) => r.json()).then(setData);
}
useEffect(() => { load(); }, [load]);
}
// ✅ Move the function inside the effect: it stops being a dependency
function StationPanel({ stationId }) {
useEffect(() => {
function load() {
fetch(`/api/estaciones/${stationId}`).then((r) => r.json()).then(setData);
}
load();
}, [stationId]);
}Moving the function inside the effect is almost always the right fix, and it has an added benefit: the linter can genuinely analyze what the function uses and compute the real dependencies. useCallback also solves this, but it's a memoization tool covered in 08-03, and using it here would be swinging a sledgehammer at a thumbtack.
Cause 3: the effect updates the state it depends on
If the goal is to count something, that's almost never an effect's job. If it genuinely were, the functional form lets you drop the dependency:
useEffect(() => {
setCounter((previous) => previous + 1);
}, [stationId]); // depends on the thing you want to count, not on the counter itself
- Antipatterns you need to recognize
Updating derived state with an effect
// ❌ The classic. Two renders, risk of desync, zero benefit
const [hours, setHours] = useState(2);
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(hours * bike.pricePerHour);
}, [hours, bike.pricePerHour]);
// ✅ A computation during render
const total = hours * bike.pricePerHour;Chaining effects that trigger one another
// ❌ Four cascading renders for a single user action
useEffect(() => {
if (selectedBike) setHours(1);
}, [selectedBike]);
useEffect(() => {
if (hours > 0) setTotal(hours * price);
}, [hours, price]);
useEffect(() => {
if (total > 0) setCanConfirm(true);
}, [total]);This chain is fragile (changing the order breaks it), slow (one render per link), and impossible to follow when reading it. The correct version does all the work in the handler that started the action and derives the rest:
// ✅ One user action, one handler, one render
function handleSelectBike(bike) {
setSelectedBike(bike);
setHours(1);
}
// Derived values, in the component body
const total = selectedBike ? hours * selectedBike.pricePerHour : 0;
const canConfirm = total > 0;Initializing the application inside a component's effect
// ❌ Runs twice with StrictMode, and doesn't depend on the component at all
function App() {
useEffect(() => {
checkUserSession();
loadGlobalConfig();
}, []);
}
// ✅ Outside React, at the module level: runs once when the app loads
if (typeof window !== 'undefined') {
checkUserSession();
loadGlobalConfig();
}
function App() { … }Submitting data in an effect instead of in the handler
// ❌ If the state gets restored for any reason, the booking gets sent again
useEffect(() => {
if (formData) submitBooking(formData);
}, [formData]);
// ✅ Submission belongs to the submit event
function handleSubmit(event) {
event.preventDefault();
submitBooking(data);
}
useLayoutEffect and modern data loading
useLayoutEffect and modern data loadingThere's a variant, useLayoutEffect, that runs after the commit but before the browser paints; it's reserved for measuring the DOM and adjusting something's position so no flicker is visible (a tooltip that needs to fit on screen), and misusing it blocks painting, so the default choice is always useEffect.
And a caveat about section 9: even though loading data with useEffect is perfectly valid and worth understanding thoroughly — it's what sits underneath everything else — in a production application you don't write it by hand. You'd be missing the cache shared across screens, deduplication of simultaneous requests, retries, revalidation when returning to the tab, and invalidation after a write. All of that gets solved by server-state libraries like TanStack Query or SWR, and also by the data loaders built into routers (Module 6) and frameworks like Next.js (Module 10). That's the subject of 07-06; for now, hold on to the underlying mechanism — it's what will let you understand those tools instead of using them blindly.
Common Mistakes and Tips
- Using
useEffectto transform data. The tell is auseStatethat only ever gets updated inside an effect. It's almost always a derived value in disguise. - Forgetting the cleanup. Timers and listeners that outlive the component are memory leaks and phantom updates. If the effect starts something, the cleanup stops it.
- Passing a different function to
removeEventListener. Store the function in a constant inside the effect and use that same reference in both calls. - Declaring the effect function as
async. React would interpret the returned promise as the cleanup function. Declare an inner function and call it instead. - Silencing
exhaustive-deps. The warning points at a real problem; hiding it just turns it into a production bug. - Depending on an object or array created during render. It's the most common cause of infinite loops. Depend on primitives instead.
- Removing
StrictModebecause "the effect runs twice". The problem is the missing cleanup, notStrictMode. - Not checking
response.ok.fetchtreats a 500 as a perfectly valid response. - Tip: before writing an effect, say out loud "this component needs to stay synchronized with ___." If you can't finish that sentence with an external system, it isn't an effect.
- Tip: a
console.logat the start of the effect and another in the cleanup, printing the dependency's value, resolves most questions about when things run.
Exercises
Exercise 1. This StationClock from CicloUrbano — the one that showed up in exercise 2 of 04-03 — has three bugs related to useEffect. Find them, explain what each one causes, and write the corrected version.
import { useState, useEffect } from 'react';
function StationClock({ station, timeZone }) {
const [time, setTime] = useState(new Date());
const [formattedTime, setFormattedTime] = useState('');
useEffect(() => {
setInterval(() => setTime(new Date()), 1000);
});
useEffect(() => {
setFormattedTime(time.toLocaleTimeString('en-GB', { timeZone }));
}, [time, timeZone]);
return <p>{station.name}: {formattedTime}</p>;
}Exercise 2. Write BikeSearch with a delay (debounce): the component keeps its own local query state (04-01) and must notify the parent with onSearch(query) only once the user has gone 400 ms without typing. Use useEffect with cleanup. Explain why the cleanup is exactly what produces the delay.
Exercise 3. The following component loads a station's detail record and has both a race condition and a leak. Rewrite it using the state structure from section 9 (a single phase variable), AbortController, an ignore flag, and HTTP error handling.
function StationDetail({ stationId }) {
const [station, setStation] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/estaciones/${stationId}`)
.then((r) => r.json())
.then((data) => { setStation(data); setLoading(false); })
.catch((e) => setError(e.message));
}, [stationId]);
if (loading) return <p>Loading…</p>;
if (error) return <p>{error}</p>;
return <h2>{station.name}</h2>;
}Solutions
Solution 1.
The three bugs:
- The first effect has no dependency array: it runs after every render. Since the effect itself updates
time, every tick triggers a render that creates another timer. After a minute there are dozens running at once and the clock jumps forward in leaps. - The first effect has no cleanup: even with
[],StrictModewould leave two timers running, and on unmount the timer would keep callingsetTimeon a component that no longer exists. - The second effect is derived state in disguise:
formattedTimeis computed entirely fromtimeandtimeZone. Both the state and the effect are unnecessary; on top of that it causes an extra render every second and makes the first render show an empty string.
// src/components/StationClock.jsx
import { useState, useEffect } from 'react';
/**
* Props:
* - station (Station object, required)
* - timeZone (string, optional)
*/
function StationClock({ station, timeZone }) {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const intervalId = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(intervalId);
}, []); // the timer doesn't depend on anything: it's created once
// DERIVED: recomputed on every render, no state or effect needed
const formattedTime = time.toLocaleTimeString('en-GB', { timeZone });
return <p>{station.name}: {formattedTime}</p>;
}
export default StationClock;Solution 2.
// src/components/BikeSearch.jsx
import { useState, useEffect } from 'react';
/**
* A search box with a delay.
* Props:
* - onSearch (function, required): receives the query after 400 ms of inactivity
* - delayMs (number, optional, defaults to 400)
*/
function BikeSearch({ onSearch, delayMs = 400 }) {
const [query, setQuery] = useState('');
useEffect(() => {
const timeoutId = setTimeout(() => onSearch(query), delayMs);
return () => clearTimeout(timeoutId);
}, [query, delayMs, onSearch]);
return (
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search bikes by model"
/>
);
}
export default BikeSearch;Why the cleanup produces the delay: every keystroke changes query, so React first runs the previous effect's cleanup — which cancels the pending timer — and then the new effect, which schedules another one 400 ms out. As long as the user keeps typing, no timer ever gets to fire: each one dies canceled by the next keystroke. Only once 400 ms pass with no changes does the last one survive and call onSearch. The delay isn't programmed anywhere explicitly: it emerges from the effect/cleanup pair.
Note: onSearch is in the dependencies because the linter requires it and it's a reactive value. If the parent recreates it on every render, this would restart the timer constantly; it gets solved with useCallback in the parent (08-03) or, better, by extracting the logic into a useDebounce hook, which is exactly what you'll do in 05-06.
Solution 3.
function StationDetail({ stationId }) {
const [phase, setPhase] = useState('loading'); // 'loading' | 'success' | 'error'
const [station, setStation] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
let ignore = false;
async function load() {
setPhase('loading');
setError(null);
try {
const response = await fetch(`/api/estaciones/${stationId}`, {
signal: controller.signal
});
if (!response.ok) throw new Error(`The server responded ${response.status}`);
const data = await response.json();
if (!ignore) {
setStation(data);
setPhase('success');
}
} catch (err) {
if (err.name === 'AbortError') return;
if (!ignore) {
setError(err.message);
setPhase('error');
}
}
}
load();
return () => { ignore = true; controller.abort(); };
}, [stationId]);
if (phase === 'loading') return <p aria-live="polite">Loading…</p>;
if (phase === 'error') return <p role="alert">{error}</p>;
return <h2>{station.name}</h2>;
}The problems with the original, and their fixes: it didn't cancel the previous request when stationId changed (race condition); it left loading stuck at true forever if the request failed, because the .catch never turned it off (contradictory state, principle 3 from 05-01); it treated a 404 as success, because fetch doesn't throw on error HTTP responses; and it could try to read station.name while station was still null. The single phase variable eliminates the impossible combinations at the root.
Conclusion
useEffect isn't "code that runs on mount": it's the tool for synchronizing a component with an external system, with two symmetric operations — start and stop synchronizing — that React repeats as many times as needed. You've seen its anatomy (effect, cleanup, dependencies), the three forms of the array and when each one fires, the real cycle with cleanup always paired to the effect, and why StrictMode's double execution is a free test that your cleanup is done right. With CicloUrbano you've written the four legitimate cases: an availability timer, a subscription to browser events, data loading with AbortController and an ignore flag against the race condition, and synchronizing the document title. And, above all, you've learned when not to use it: transforming data is render, responding to a click is a handler, resetting state is key, and chained effects are a cascade of renders that almost always hides a derived value gone wrong.
One piece is left that's shown up in passing several times. Section 7's timer returned an id that had to be kept around; exercise 2's search box needed to remember the pending timer; and 03-05 mentioned a way to read a form field by reaching straight into the DOM node. All of that comes down to values that need to be remembered across renders but must never trigger one, plus React's controlled escape hatch to the page's real elements: focusing a field, measuring an element, scrolling a list, opening a <dialog>. The next lesson is The useRef Hook and DOM Access.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
