Goal: use effects to keep a component in sync with systems outside React, with the right dependencies and cleanup, recognise when an effect is the wrong tool, and use refs for DOM nodes and values that should not trigger a render.
Everything so far has stayed inside React: state goes in, JSX comes out. Real apps also have to talk to things React doesn’t manage. The tool library wants the browser tab to show how many tools are in the basket, a keyboard shortcut that jumps to the search box, and the next free date for a tool from the server. Those are jobs for useEffect and useRef. They are also the two hooks most often misused, so this lesson spends as much time on when not to use them as on how they work.
In this lesson
What an effect is for
Lesson 1 said renders must be pure: no network requests, no timers, no touching the DOM. But something has to set the page title. An effect is code that React runs after it has committed a render to the screen, whose job is to make an outside system match what was just rendered. “Outside” means anything React doesn’t control: document.title, a browser event listener, a timer, a network connection, a map or chart widget written in plain JavaScript.
Here the basket count is synchronised with the page title. (The result runs in a frame, so you can’t see its title in your browser tab; the effect logs it instead.)
import { useEffect, useState } from 'react';
function BasketTitle() {
const [count, setCount] = useState(0);
const [showTips, setShowTips] = useState(false);
useEffect(() => {
document.title = count === 0 ? 'Tool library' : `Tool library (${count} in basket)`;
console.log('Effect ran. Title:', document.title);
}, [count]);
return (
<div>
<p>Tools in your basket: {count}</p>
<button onClick={() => setCount(count + 1)}>Add a tool</button>{' '}
<button onClick={() => setShowTips(!showTips)}>{showTips ? 'Hide tips' : 'Show tips'}</button>
{showTips && <p>Tip: you can borrow up to 3 tools at once.</p>}
</div>
);
}
The console shows “Effect ran. Title: Tool library” once, after the first render. Press Add a tool and it logs again with “Tool library (1 in basket)”. Now press Show tips: the component re-renders, but the effect does not run, because the only value it depends on, count, has not changed.
Think of an effect as a sentence: “after rendering, make the title match count“. You describe the synchronisation; React decides when it needs to happen. That is a different mindset from an event handler, which says “when the user does this, do that“. If code should run because of a specific user action, it belongs in the handler. If it should run because the component is on the screen with certain values, it may belong in an effect.
The dependency array
The second argument to useEffect tells React which values from the component the effect reads. After each render React compares every dependency with its value from the previous render (using Object.is, so objects and arrays compare by identity, not content) and re-runs the effect only if one changed:
useEffect(() => { /* … */ }); // after every render
useEffect(() => { /* … */ }, []); // after the first render only
useEffect(() => { /* … */ }, [toolId]); // after the first render, then whenever toolId changes
The list is not a setting you choose to control when the effect runs. It must contain every reactive value the effect uses: props, state, and anything calculated from them inside the component. If you leave one out, the effect keeps using the value from an old render and the outside world drifts out of sync, a “stale closure” bug. The react-hooks/exhaustive-deps rule from eslint-plugin-react-hooks, included in the Vite React template and in Next.js’s ESLint config, flags missing dependencies. Treat its warnings as bugs to understand, not noise to silence.
What if the effect runs too often? Don’t remove the dependency; change the code so it no longer needs it. Move an object or function the effect uses inside the effect, or outside the component if it doesn’t depend on props or state. Then it is no longer a new value on each render.
Cleanup, and why Strict Mode runs effects twice
Many effects start something that keeps going: a listener, a timer, a connection. An effect can return a cleanup function that stops it. React calls the cleanup before it runs the effect again with new values, and once more when the component is removed from the screen (unmounted). Setup and cleanup should mirror each other: whatever one adds, the other removes.
The tool library’s search shortcut is a good example. Pressing / anywhere on the page should focus the search box, unless the member is already typing in a field. The listener must go on window, which React doesn’t manage, so it needs an effect:
import { StrictMode, useEffect, useRef, useState } from 'react';
function SearchBar() {
const inputRef = useRef(null);
useEffect(() => {
function handleKeyDown(e) {
const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
if (e.key === '/' && !typing) {
e.preventDefault();
inputRef.current.focus();
}
}
window.addEventListener('keydown', handleKeyDown);
console.log('listener added');
return () => {
window.removeEventListener('keydown', handleKeyDown);
console.log('listener removed');
};
}, []);
return (
<p>
<label htmlFor="tool-search">Search tools</label>{' '}
<input id="tool-search" ref={inputRef} placeholder="Press / to search" />
</p>
);
}
function App() {
const [shown, setShown] = useState(false);
return (
<StrictMode>
<button onClick={() => setShown(!shown)}>{shown ? 'Hide search' : 'Show search'}</button>
{shown && <SearchBar />}
</StrictMode>
);
}
Press Show search, then click on an empty part of the result (so it has keyboard focus) and press /: the cursor jumps into the search box, and the slash is not typed into it because of preventDefault(). Typing / once you are in the box works normally.
Now look at the console. It says “listener added”, “listener removed”, “listener added”, although the search box appeared only once. That is Strict Mode. In a real project it wraps the whole app where the root is created (Vite’s template does this, and the Next.js App Router turns it on by default); here it wraps the part of the example that appears when you press the button. In development only, React mounts each component, runs its effects, cleans them up and runs them again straight away. It is a deliberate test: if your cleanup is missing or incomplete, the second setup makes the bug visible immediately. Here, without the cleanup, there would be two listeners on window. In production the effect runs once.
Press Hide search and the console logs one more “listener removed”: the component unmounted and React ran the cleanup. Without it the listener would outlive the search box and call focus() on an input that no longer exists. The fix for “my effect runs twice” is almost never to prevent the second run; it is to write a cleanup that makes running twice harmless.
Fetching data without race conditions
A tool’s detail panel shows when it is next free, loaded from the server. The request depends on which tool is selected, so it is a synchronisation: “while this tool is shown, display its availability”. Requests take time, though, and they don’t always come back in the order they were sent. If a member clicks the drill and then quickly the ladder, the drill’s slower answer could arrive last and overwrite the ladder’s.
The fix is an ignore flag that the cleanup sets. When the selected tool changes, React cleans up the previous effect, which marks its request as out of date; when that answer finally arrives, it is dropped. This version of the course’s fetchAvailability makes the drill slow on purpose (2 seconds, 0.3 for the others) so the race is easy to trigger:
import { useEffect, useState } from 'react';
const TOOLS = [
{ id: 't1', slug: 'cordless-drill', name: 'Cordless drill', category: 'Power tools', status: 'available', maxLoanDays: 7 },
{ id: 't2', slug: 'extension-ladder', name: 'Extension ladder', category: 'Ladders & access', status: 'on-loan', maxLoanDays: 3 },
];
const nameOf = (id) => TOOLS.find((t) => t.id === id).name;
// Demo version: the drill's answer is slow on purpose.
function fetchAvailability(toolId) {
const delay = toolId === 't1' ? 2000 : 300;
return new Promise((resolve) => {
setTimeout(() => resolve({ toolId, nextFreeDate: toolId === 't1' ? '2026-12-01' : '2026-12-08' }), delay);
});
}
function Availability() {
const [toolId, setToolId] = useState('t2');
const [result, setResult] = useState(null);
useEffect(() => {
let ignore = false;
fetchAvailability(toolId).then((data) => {
if (!ignore) setResult(data);
});
return () => { ignore = true; };
}, [toolId]);
function choose(id) {
setToolId(id);
setResult(null);
}
return (
<div>
{TOOLS.map((t) => <button key={t.id} onClick={() => choose(t.id)}>{t.name}</button>)}
<p>Selected: {nameOf(toolId)}</p>
<p>{result ? `${nameOf(result.toolId)} is next free on ${result.nextFreeDate}` : 'Checking availability…'}</p>
</div>
);
}
It loads “Extension ladder is next free on 2026-12-08”. Click Cordless drill, then within two seconds Extension ladder: the ladder’s date appears and stays. Now delete the if (!ignore) check (keep setResult(data);), run it and repeat. The ladder’s date appears, then the drill’s late answer replaces it: “Selected: Extension ladder” above “Cordless drill is next free on 2026-12-01”. That is a race condition, and it is exactly the kind of bug that passes every test on a fast office connection.
Notice that clearing the old result happens in choose, the click handler, not in the effect. It is a direct response to the click, so it belongs there. With the real fetch, you can go one step further and cancel the request itself with an AbortController:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/tools/${toolId}/availability`, { signal: controller.signal })
.then((res) => res.json())
.then(setResult)
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [toolId]);
Fetching in effects works, but it leaves you to handle loading states, errors, caching and the network waterfall yourself. In real projects, data usually comes from your framework (in Next.js, Server Components fetch on the server; Lesson 27) or from a data library. React 19’s use with Suspense, in Lesson 14, is another option. Understanding the effect version still matters: it shows you what those tools solve.
You might not need an effect
Once you know useEffect, it is tempting to use it whenever something should “happen when X changes”. Most of those cases have nothing to do with an outside system, and an effect makes them slower and buggier. The classic one in a catalogue:
// Avoid: a copy of derived data, kept in sync by an effect.
const [query, setQuery] = useState('');
const [results, setResults] = useState(TOOLS);
useEffect(() => {
setResults(TOOLS.filter((t) => t.name.toLowerCase().includes(query.toLowerCase())));
}, [query]);
After each keystroke React renders with the old results, commits them, runs the effect, then renders and commits a second time with the new ones. It is more code, twice the work, and for a moment the screen disagrees with the search box. The calculation needs nothing outside React, so do it during render, as you did in Lesson 7:
const [query, setQuery] = useState('');
const results = TOOLS.filter((t) => t.name.toLowerCase().includes(query.toLowerCase()));
A few more patterns to catch in review:
- Reacting to a user action. “When the basket count goes up, show ‘Added to basket’” in an effect also fires when the basket is restored from storage on page load. The message is about the click, so set it in the click handler.
- Telling the parent about a change. Calling
onChange(value)from an effect after setting state adds an extra render pass. Call the parent’s callback in the same handler that sets the state. - Resetting state when a prop changes. A review form that should empty itself when you open a different tool doesn’t need an effect watching
toolId. Render it as<ReviewForm key={toolId} />: a new key gives a fresh component with fresh state, as Lesson 7 explained for list items.
A quick test: if you can name the outside system the effect talks to (the document title, window, a server, a timer), an effect may be right. If the effect only reads React state and sets other React state, it is very likely a calculation or a handler in disguise.
useRef: DOM nodes and values that don’t render
useRef(initialValue) gives you a plain object, { current: initialValue }, that React keeps for the life of the component. Unlike state, changing ref.current does not cause a re-render, and unlike an ordinary variable inside the component, it is not reset on every render. That makes it right for two jobs.
1. Holding a DOM node. Pass the ref to an element’s ref attribute, as the search shortcut did, and React sets inputRef.current to the input element when it commits, and back to null when the element is removed. Use it for the things JSX can’t express: focusing a field, scrolling an element into view, measuring its size, or handing a node to a non-React widget. In React 19 your own components can accept ref as an ordinary prop and pass it on, so function SearchInput({ ref, ...props }) { return <input ref={ref} {...props} />; } works without the older forwardRef wrapper. This is also how you would move focus to the first invalid field of the Lesson 8 reservation form.
2. Remembering a value that the screen doesn’t show. A timer id is the textbook case. Volunteers save repair notes, and a “Repair note saved” message should disappear two seconds later. Saving twice quickly must restart the timer, not leave the first one to hide the message early:
import { useEffect, useRef, useState } from 'react';
function RepairNote() {
const [message, setMessage] = useState('');
const timerRef = useRef(null);
function handleSave() {
clearTimeout(timerRef.current);
setMessage('Repair note saved.');
timerRef.current = setTimeout(() => setMessage(''), 2000);
}
// If the component is removed, don't leave the timer running.
useEffect(() => () => clearTimeout(timerRef.current), []);
return (
<div>
<label htmlFor="repair-note">Hedge trimmer: repair note</label>
<textarea id="repair-note" rows={2} style={{ display: 'block', width: '100%' }} defaultValue="Blade needs sharpening." />
<button onClick={handleSave}>Save note</button>
<p role="status">{message}</p>
</div>
);
}
Press Save note: the message appears and goes two seconds later. Press it several times in a row and the message stays until two seconds after the last press. The timer id lives in a ref because no part of the screen depends on it; putting it in state would cause a pointless re-render each time it changes. A let timer inside the component would not work, because each render creates a fresh variable and the old id would be lost. The role="status" paragraph is a live region: screen readers announce its new text without moving focus.
One rule keeps refs safe: don’t read or write ref.current during render (the one accepted exception is setting it once when it is still empty, to initialise it lazily). Read and write it in event handlers and effects. If a value appears on screen, it belongs in state, so that React re-renders when it changes.
Common mistakes
- Effects for derived data. An effect whose only job is
setSomething(calculation(otherState)). Look for effects with no outside system in them; replace them with a calculation during render. - Lying about dependencies. An empty array on an effect that reads props or state, or an
eslint-disablecomment above the dependency list. The effect will use stale values. Fix the code, not the list. - Missing cleanup.
addEventListener,setInterval,setTimeoutor a subscription in an effect with noreturn. Every “start” in an effect needs a matching “stop” in its cleanup. - No protection against out-of-order responses. A fetch in an effect that sets state unconditionally. Look for an
ignoreflag or anAbortController. - Refs for values the screen shows. Updating
countRef.currentand expecting the page to change. Nothing re-renders; use state.
Build it: search shortcut and basket title
The starter is a small catalogue with a search box and a basket. Add two effects and a ref:
- Pressing / focuses the search box, unless focus is already in a form field. Clean the listener up.
- The page title is “Tool library” with an empty basket and “Tool library (2 in basket)” otherwise. Log it with
console.logso you can see it in the editor. - Keep the filtered list and the “basket is full” check as calculations. No effect should set state.
import { useEffect, useRef, useState } from 'react';
const TOOLS = [
{ id: 't1', slug: 'cordless-drill', name: 'Cordless drill', category: 'Power tools', status: 'available', maxLoanDays: 7 },
{ id: 't3', slug: 'orbital-sander', name: 'Orbital sander', category: 'Power tools', status: 'available', maxLoanDays: 7 },
{ id: 't6', slug: 'tile-cutter', name: 'Tile cutter', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
{ id: 't7', slug: 'stud-finder', name: 'Stud finder', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
];
function Catalogue() {
const [query, setQuery] = useState('');
const [basket, setBasket] = useState([]);
const results = TOOLS.filter((t) => t.name.toLowerCase().includes(query.trim().toLowerCase()));
const full = basket.length >= 3;
return (
<div>
<label htmlFor="search">Search tools</label>{' '}
<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((t) => (
<li key={t.id}>
{t.name}{' '}
<button aria-label={`Add ${t.name} to basket`} disabled={full || basket.includes(t.id)} onClick={() => setBasket([...basket, t.id])}>Add</button>
</li>
))}
</ul>
<p>In basket: {basket.length} of 3</p>
</div>
);
}
Hint: two separate effects are clearer than one, because they synchronise different things and depend on different values. The shortcut effect depends on nothing reactive (refs are stable), so its array is empty; the title effect depends on basket.length.
Show a sample solution
import { useEffect, useRef, useState } from 'react';
const TOOLS = [
{ id: 't1', slug: 'cordless-drill', name: 'Cordless drill', category: 'Power tools', status: 'available', maxLoanDays: 7 },
{ id: 't3', slug: 'orbital-sander', name: 'Orbital sander', category: 'Power tools', status: 'available', maxLoanDays: 7 },
{ id: 't6', slug: 'tile-cutter', name: 'Tile cutter', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
{ id: 't7', slug: 'stud-finder', name: 'Stud finder', category: 'Hand tools', status: 'available', maxLoanDays: 7 },
];
function Catalogue() {
const [query, setQuery] = useState('');
const [basket, setBasket] = useState([]);
const searchRef = useRef(null);
const results = TOOLS.filter((t) => t.name.toLowerCase().includes(query.trim().toLowerCase()));
const full = basket.length >= 3;
const count = basket.length;
useEffect(() => {
function handleKeyDown(e) {
if (e.key === '/' && !e.target.closest('input, textarea, select, [contenteditable="true"]')) {
e.preventDefault();
searchRef.current.focus();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
useEffect(() => {
document.title = count === 0 ? 'Tool library' : `Tool library (${count} in basket)`;
console.log('Title:', document.title);
}, [count]);
return (
<div>
<label htmlFor="catalogue-search">Search tools</label>{' '}
<input id="catalogue-search" ref={searchRef} value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((t) => (
<li key={t.id}>
{t.name}{' '}
<button aria-label={`Add ${t.name} to basket`} disabled={full || basket.includes(t.id)} onClick={() => setBasket([...basket, t.id])}>Add</button>
</li>
))}
</ul>
<p>In basket: {count} of 3</p>
</div>
);
}
The console logs “Title: Tool library” on load and “Title: Tool library (1 in basket)” after the first Add. Depending on count (a number) rather than basket (an array) means the title effect re-runs only when the number changes, which states the intent precisely. Typing in the search box re-renders the list without touching either effect. In development with Strict Mode on, the title effect would run twice on load, which is harmless: setting the same title twice gives the same result.
Check your understanding
Answer each question in a sentence or two before opening the answer.
1. The availability effect reads toolId, but someone wrote [] as its dependencies. What does the member see?
The effect runs once, for the tool selected when the component first appeared. Selecting another tool changes toolId, but the effect never runs again, so the panel keeps showing the first tool’s date (or, with the result cleared in the handler, “Checking availability…” forever). The lint rule would have flagged the missing toolId.
2. You suspect the “/” shortcut’s listener is never removed. How would you prove it?
Add a console.log inside the handler and render the component inside <StrictMode>. Strict Mode sets the effect up twice in development, so without a cleanup there are two listeners and each / press logs twice. You can also hide and show the component a few times and watch the count grow, or check the listeners on window in Chrome DevTools (Elements panel, Event Listeners tab).
3. Why is the timer id in RepairNote kept in a ref, not in state or a plain variable?
Nothing on screen depends on the id, so state would cause re-renders for no reason. A plain variable declared in the component is recreated on every render, so the id saved in one render’s handler would be gone in the next and clearTimeout could not cancel the old timer. A ref survives between renders without triggering any.
Key takeaways
- An effect synchronises a component with something outside React, after the render is committed. If there is no outside system, you probably don’t need one.
- The dependency array lists every reactive value the effect reads. Change the code, not the list, when an effect runs too often.
- Return a cleanup that undoes the setup. Strict Mode’s extra setup and cleanup in development exists to find the cases where you forgot.
- Guard fetches in effects against out-of-order answers with an
ignoreflag orAbortController. useRefholds a DOM node or a value that survives renders without causing one. Don’t read or write it during render.
Priority: must understand. You should be able to write an effect with correct dependencies and cleanup, explain the Strict Mode double run, and point out an unnecessary effect in someone else’s code. For the details, see You Might Not Need an Effect and the useEffect reference in the React documentation.
