The proper use of useEffect
The proper use of useEffect#
useEffect is for side effects: stepping outside React to sync a component with an external system (network, timers, subscriptions, browser APIs). The typical case is async work: a promise whose result arrives after the render.
Ref: Synchronizing with Effects
The standard shape#
It’s literally the first page of the official useEffect documentation.
useEffect(() => {
// 1. setup: start the side effect
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setUser)
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
});
// 2. cleanup: stop or neutralize it
return () => controller.abort();
}, [userId]); // 3. dependencies: re-run when they change
fetch rejects only on network errors: an HTTP error (4xx, 5xx) still resolves, so check res.ok and throw. All the examples below do it.
Implement the cleanup. React runs it before re-running the effect (when a dependency changes) and on unmount. Without it you get:
- Race conditions:
userIdgoes from1to2, the response for1arrives after the one for2and overwrites it. - Performance degradation: listeners, intervals and subscriptions pile up on every re-run, and requests keep running for unmounted components.
A complex case: reload an API call#
A list with a “Reload” button, a sort of manual retry. This is often written wrong.
Wrong#
function Users() {
const [loading, setLoading] = useState(false);
const [resp, setResp] = useState(null);
const [error, setError] = useState(null);
const load = useCallback(() => {
setLoading(true);
fetch('/api/users')
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((resp) => {
setResp(resp);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, []);
useEffect(() => {
load();
}, [load]);
return <button onClick={load}>Reload</button>;
}
- Race condition: click twice, the two requests run together, and the slowest one wins, even if it’s the oldest.
- Inconsistent state:
loadingbecomesfalsewhen the first request ends, while the second one is still running. - No cleanup: requests keep running after unmount, and the fetch is started from two places (the effect and the handler).
Right#
The handler doesn’t fetch, it only asks for a new load by changing a trigger. The effect does the fetch, and the response is stored together with the trigger that produced it:
function Users() {
const [trigger, setTrigger] = useState(0);
const [state, setState] = useState({ trigger: null, resp: null, error: null });
const loading = state.trigger !== trigger;
useEffect(() => {
const controller = new AbortController();
fetch('/api/users', { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((resp) => setState({ trigger, resp, error: null }))
.catch((error) => {
if (error.name !== 'AbortError') setState({ trigger, resp: null, error });
});
return () => controller.abort();
}, [trigger]);
return (
<>
<button onClick={() => setTrigger((t) => t + 1)}>
{loading ? 'Loading…' : 'Reload'}
</button>
{/* render state.resp or state.error */}
</>
);
}
- No race condition: a new click changes
trigger, React runs the cleanup, the previous request is aborted, and only the latest one can update the state. loadingis computed, not stored: it’strueuntil the state holds the response for the currenttrigger, so it can’t go out of sync.- Everything follows the React flow: event → state change → render → effect → state change. While reloading, the previous
respstays on screen until the new one arrives. StrictModeruns the effect twice in development: the first request is aborted, and nothing breaks.
Same pattern: search as you type#
Instead of a trigger, use the search itself: the user types in an input, every change updates search, and every new search calls the API.
function UserSearch() {
const [search, setSearch] = useState(''); // empty search = all users
const [state, setState] = useState({ search: null, resp: null, error: null });
const loading = state.search !== search;
// the response only when it matches the current search, undefined while loading
const data = loading ? undefined : state.resp;
useEffect(() => {
const controller = new AbortController();
// debounce: wait 300ms after the last keystroke before calling the API
const timeout = setTimeout(() => {
fetch(`/api/users?q=${encodeURIComponent(search)}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((resp) => setState({ search, resp, error: null }))
.catch((error) => {
if (error.name !== 'AbortError') setState({ search, resp: null, error });
});
}, 300);
// without the debounce, the cleanup is just:
// return () => controller.abort();
return () => {
clearTimeout(timeout); // the request has not started yet: skip it
controller.abort(); // the request is running: abort it
};
}, [search]);
return (
<>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
{loading && <Spinner />}
{/* render data or state.error */}
</>
);
}
- Debounce: typing
a,ad,adaquickly clears the pending timeouts, so only one request starts, forada. - No race condition: if the user types again while a request is running, the cleanup aborts it. You are sure that
state.respis the response for the latest search, never an older one arriving late. state.searchtells you which search the response belongs to, soloadingis computed again, and it’strueduring the debounce too.datais computed too:state.respkeeps the previous response while loading,datadoesn’t. No need to callsetState({ resp: null })at the beginning of the effect: the effect runs after the render, so you get one render with the stale data plus an extra render. Computed during render,dataisundefinedin the same render in whichsearchchanges: fast feedback, always in sync withloading.- In a custom hook (e.g.
useUserSearch),datais what you return:undefinedwhile loading, the latest response otherwise.
Tip: keep the effect short, store the raw data#
When things get complex, this mindset helps a lot: the effect only gets the data and stores it raw in a state. Everything else is derived from the raw state during render (with useMemo if necessary).
Avoid computing derived data inside the effect and spreading it over multiple states:
// Wrong: the effect transforms the response and discards the rest
.then((resp) => {
setUsers(resp.users.filter((u) => u.active));
setTotal(resp.total);
});
// Right: the effect stores the raw response...
.then((resp) => setState({ search, resp, error: null }));
// ...and the rest is derived during render
const activeUsers = useMemo(
() => data?.users.filter((u) => u.active) ?? [],
[data],
);
As the code evolves, you will need some data that an earlier transformation discarded (e.g. the inactive users). With the transformations in the effect, you have to add a new state for it and grow the effect. With the raw data in state, it’s just one more derived variable.
No cleanup? It’s a smell#
A few effects legitimately have nothing to clean up: setting document.title, focusing an input on mount, logging a page view. They are the exception. If you can’t write a meaningful cleanup, there is probably no external system to sync with, and you probably don’t need an effect. Ask yourself:
- Am I computing a value from props or state? → Compute it during render (
useMemoif it’s expensive). - Am I reacting to a user action? → Put the logic in the event handler.
- Am I copying a prop or a param into state? → See the examples below.
- Does the effect only call
setState? → It costs an extra render, and the first render shows a stale value.
Ref: You Might Not Need an Effect
Smell: a POST in an effect#
A user action (e.g. clicking “Create”) sets a state, and an effect sends the POST:
const [toCreate, setToCreate] = useState(null);
useEffect(() => {
if (!toCreate) return;
const controller = new AbortController();
fetch('/api/projects', {
method: 'POST',
body: JSON.stringify(toCreate),
signal: controller.signal,
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setCreated);
return () => controller.abort();
}, [toCreate]);
// ...
<button onClick={() => setToCreate({ name })}>Create</button>;
The cleanup is there, but why would you abort a POST? If the request has been sent and the response hasn’t arrived yet, aborting doesn’t stop the backend: the project gets created anyway. Worse, you lose the response, so you don’t know whether it succeeded or not. And in development, StrictMode runs the effect twice, so it may send the POST twice.
So why not just send the POST in the event handler?
const [saving, setSaving] = useState(false);
const onCreate = async () => {
setSaving(true);
try {
const res = await fetch('/api/projects', {
method: 'POST',
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setCreated(await res.json());
} catch (error) {
setError(error);
} finally {
setSaving(false);
}
};
// ...
<button onClick={onCreate} disabled={saving}>
Create
</button>;
- It runs once per click, and you always get the response.
disabled={saving}prevents a double POST from a double click.
The exception: requests whose result you don’t care about (at least not to show it to the user), like sending monitoring events.
Copy vs join#
Most effects without a cleanup exist to copy a value from props or params into state, and then keep the copy up to date. This is the principle behind the following examples: a copy means two sources (the props/params and the state) that you have to keep aligned by hand. Instead, join them: keep in state only what the user changed, and compute the rest during render.
Each example starts from the wrong version, so you learn to recognize the smell (an effect that only syncs a value into state), then shows how to remove the effect by joining the sources.
Example 1: override a value on prop change#
Wrong#
function NameForm({ value, onSave }) {
const [name, setName] = useState(value);
useEffect(() => {
setName(value);
}, [value]);
// ...
}
- Two sources of truth (the prop and the state), kept in sync by hand.
- When
valuechanges, the component first renders the stalename, then renders again. - No cleanup: smell.
Right#
Keep in state only what the user changed, initially null, and fall back to the prop:
function NameForm({ value, onSave }) {
const [edited, setEdited] = useState(null); // null = not changed yet
const name = edited ?? value;
const changed = name !== value;
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSave(name);
}}
>
<input value={name} onChange={(e) => setEdited(e.target.value)} />
<button type="submit" disabled={!changed}>
Save
</button>
</form>
);
}
- Until the user types, the component follows the prop.
changedis computed too, no extra state: it enables the save button only when there is something to save.- Prefer
??over||: with||, an emptied input ('') would fall back to the prop.
Ref: Adjusting some state when a prop changes
Example 2: default value#
The value lives in the URL params (here with React Router’s useSearchParams), with a default when it’s missing.
Wrong#
const [searchParams, setSearchParams] = useSearchParams();
const [sort, setSort] = useState('newest');
useEffect(() => {
setSort(searchParams.get('sort') ?? 'newest');
}, [searchParams]);
You might be tempted to write useState(searchParams.get('sort')) instead, but why create a state at all? The value is already in the params, so why duplicate it? Besides, the initial value is read only on the first render: when the params change later (back button, a link), the state goes stale.
Right#
The params are the source of truth: read the value with its default, and write it back on change.
function SortSelect() {
const [searchParams, setSearchParams] = useSearchParams();
const sort = searchParams.get('sort') ?? 'newest';
const onChange = (e) => {
setSearchParams((prev) => {
prev.set('sort', e.target.value);
return prev;
});
};
return (
<select value={sort} onChange={onChange}>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
</select>
);
}
No state, no effect: one source, always aligned.
Example 2b: dynamic default value#
Sometimes the default is not a constant but something dynamic, e.g. coming from an API: a filter by project id, where the id can be set in the URL, and when it’s missing the first project of a list fetched from the API is used.
function ProjectFilter() {
const [searchParams, setSearchParams] = useSearchParams();
const { data: projects, isLoading } = useProjects(); // list from the API
if (isLoading) return <Spinner />;
const projectId = searchParams.get('projectId') ?? projects[0]?.id;
const onChange = (e) => {
setSearchParams((prev) => {
prev.set('projectId', e.target.value);
return prev;
});
};
// ...
}
Don’t write the default into the URL: a page without projectId means “use the default”, so the URL changes only when the user picks a project.
Example 3: defaults plus user changes#
A form with initial values from the params, which the user can edit:
- the params are the base: map them to the form fields, pick only the keys you need and add fallbacks;
- the state holds only the edits;
- the data is the merge: the edits override the base where needed.
function ProfileForm({ params, onSubmit }) {
const [edits, setEdits] = useState({}); // only the fields the user changed
// base: form fields from the params, with fallbacks
const defaults = {
name: params.the_name ?? '',
email: params.email ?? '',
role: params.role ?? 'viewer',
};
// data: the edits override the defaults
const data = { ...defaults, ...edits };
const changed = Object.keys(edits).length > 0;
const update = (field) => (e) =>
setEdits((prev) => ({ ...prev, [field]: e.target.value }));
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit(data);
}}
>
<input value={data.name} onChange={update('name')} />
<input value={data.email} onChange={update('email')} />
<button type="submit" disabled={!changed}>
Save
</button>
</form>
);
}
With params = { the_name: 'Ada', email: 'ada@old.io', page: '2' } and the user editing only the email:
defaults // { name: 'Ada', email: 'ada@old.io', role: 'viewer' }
edits // { email: 'ada@new.io' }
data // { name: 'Ada', email: 'ada@new.io', role: 'viewer' }
page never reaches the form or the API, the fields the user didn’t touch keep following the params, and data is what fills the form and is sent via API.
Wrapping up#
Before writing a useEffect, ask yourself:
- Is there an external system to sync with (network, timers, subscriptions, browser APIs)? If not, you probably don’t need an effect.
- Can I write the cleanup? If not, question the effect.
- Is it a reaction to a user action? Put it in the event handler, e.g. a POST.
- Is the effect short? Store the raw data and derive everything else during render.
- Is there a single source of truth? Don’t copy props or params into state: keep in state only what the user changed and compute the rest during render.