React Basics #10: useEffect
Last time we covered the canonical form pattern, controlled components. So far, every component you’ve built has started and finished its work inside itself. The cycle of taking user input, holding it as state, and drawing it on screen has been entirely closed within the component. This time we’ll learn useEffect, the tool you use when a component needs to interact with the outside world.
What is a side effect? #
A side effect is anything beyond a component’s core role of “turning props/state into JSX.”
- Fetching data from a server (
fetch) - Setting timers (
setTimeout,setInterval) - Using browser APIs (
localStorage, changingdocument.title, etc.) - Initializing external libraries
- Registering event listeners (
window.addEventListener)
These are all separate from producing render output (JSX). But they’re not unrelated to the component either — what to fetch and when to start/stop a timer depend on the component’s props/state.
React recommends that you handle these tasks inside useEffect rather than directly in the component function body.
Why not just write it in the function body? #
Look at this code.
function Profile({ userId }) {
const [user, setUser] = useState(null);
// fetch directly in the function body — no
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
return user ? <p>{user.name}</p> : <p>Loading...</p>;
}Two problems:
- A fetch happens on every render. When
setUserupdates state, the component re-renders, fetches again, calls setUser again, … an infinite loop. - It violates React’s principle that rendering should be fast and pure.
useEffect is the mechanism that pulls these tasks out so they run after rendering finishes, and only when they need to.
Basic useEffect #
import { useState, useEffect } from 'react';
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
return user ? <p>{user.name}</p> : <p>Loading...</p>;
}
export default Profile;The shape:
useEffect(() => {
// code to run
}, [dependency, array]);- First argument: the effect function (the code to run)
- Second argument: the dependency array (re-run the effect only when these values change)
React runs the effect function after the component is on screen. On the next render, if the values in the dependency array are the same as before, it skips the effect; if they differ, it runs again.
Three forms of the dependency array #
1. Empty array [] — once at the start
#
useEffect(() => {
console.log('the component first appeared');
}, []);When the array is empty, there are no values to depend on, so the effect runs only once. Common for initial data loading and one-time setup.
2. Specified dependencies [a, b] — whenever those values change
#
useEffect(() => {
fetch(`/api/users/${userId}`).then(/* ... */);
}, [userId]);When userId changes, fetch again. When the same value comes in, it doesn’t run again — efficient. The basic rule is: every prop/state used inside the effect should be in the dependency array (otherwise you’ll get bugs from stale references).
3. No array at all — every render #
useEffect(() => {
console.log('rendered');
});Omit the array entirely and the effect runs on every render. Almost never useful, and it’s usually the cause of unintended infinite loops, so avoid it consciously.
react-hooks/exhaustive-deps rule automatically catches missing dependencies. Vite’s default ESLint config includes it, so you’ll see warnings as you write code. Don’t ignore the warnings; following them is usually correct.Cleanup function #
Resources that an effect registers (timers, event listeners, subscriptions, …) often need to be cleaned up. When the component goes off screen, or just before the dependencies change and the effect runs again, you have to clean up the previous effect.
If useEffect’s effect function returns a function, React calls that function at cleanup time.
import { useState, useEffect } from 'react';
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id); // cleanup
}, []);
return <p>{time.toLocaleTimeString()}</p>;
}
export default Clock;When this component disappears, React calls the returned function and clearInterval clears the timer. Without cleanup, the timer would keep running after the component is gone, leaking memory and causing unexplained bugs.
Cleanup is also called when dependencies change #
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);- Fetch starts with
userIdof1 - Before the response arrives, the user navigates and
userIdbecomes2 - React runs the previous effect’s cleanup first (
cancelled = true) - The new effect runs and starts a fetch for
2 - The late response for
1is ignored becausecancelled === true
Handling race conditions like this is another common use of cleanup.
Common patterns #
Fetching data #
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('request failed');
return res.json();
})
.then(data => setUser(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;
return <p>{user.name}</p>;
}Expressing every state of an async request with three states — loading/error/data — is the canonical pattern. Using .finally() on the Promise to turn off loading handles both success and failure consistently.
useEffect + fetch by hand. It handles caching, retries, background sync, and so on. But these libraries are themselves built on useEffect, so understanding the underlying mechanism still helps.Registering event listeners #
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <p>Window width: {width}px</p>;
}addEventListener ↔ removeEventListener must be paired, so cleanup is essential.
Syncing external state like document.title #
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
// ...
}document.title lives outside React’s managed area, so syncing it with our state requires an effect.
Syncing with localStorage #
function Settings() {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') ?? 'light';
});
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
// ...
}When you pass a function as useState’s initial value, it runs only on mount (preventing localStorage.getItem from being called every render). After that, whenever theme changes, the effect saves the new value.
Common mistakes #
1. Missing dependencies #
useEffect(() => {
fetch(`/api/users/${userId}`).then(/* ... */);
}, []);With an empty array, you fetch with the userId from the first render only, and don’t refetch when userId changes later. ESLint catches this — follow the warning.
2. Endlessly setting state inside an effect #
useEffect(() => {
setCount(count + 1); // changing count inside a [count] dependency — no
}, [count]);Changing state triggers a re-render, dependencies have changed, the effect runs again, state changes again … an endless loop. An effect’s job is to change the outside world, not to keep changing its own state.
3. Overusing useEffect #
The React docs emphasize this — don’t use useEffect for things you can solve with a calculation. For example, making a state for “first name + last name” via useEffect is overkill. Just compute it as a variable in the function body.
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);const fullName = `${firstName} ${lastName}`;To decide whether useEffect is really needed, ask yourself: “Is this work related to the outside world (server, timer, DOM API, …)?” If not, you almost certainly don’t need useEffect.
Try it yourself #
Let’s build a small clock + page-title sync component.
src/ClockTitle.jsx:
import { useState, useEffect } from 'react';
function ClockTitle() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
document.title = `Now: ${time.toLocaleTimeString()}`;
}, [time]);
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>{time.toLocaleTimeString()}</h2>
<p>Check that the browser tab title also changes.</p>
</div>
);
}
export default ClockTitle;src/App.jsx:
import ClockTitle from './ClockTitle';
function App() {
return <ClockTitle />;
}
export default App;Save and the clock updates every second; the browser tab’s title changes along with it. You can see two useEffects cooperating, each doing its own job. The first updates the time every second, and the second syncs the tab title whenever the time changes.
Wrapping up #
In this article we covered useEffect, the tool a component uses to interact with the outside world. The essentials:
useEffect(fn, deps)— runfnwheneverdepschange[]= once on mount,[a]= whenachanges, omitted = every render- Returning a function makes it the cleanup (clearing timers/listeners/subscriptions)
- All props/state used inside the effect must be in the dependencies
- Don’t use effects for simple computations — use a variable
Every component we’ve worked with so far had its own state. But what if two sibling components need to share state? In the next article, “React Basics #11: Lifting state up,” we’ll cover the core pattern for this case — lifting state up.