React useEffect: What Shouldn’t Go Inside

When you start seeing the following signs in a component, it basically means useEffect has gotten out of control:
A bunch of effects are passing the ball to each other — A changes and triggers B, B triggers C.
Just to calculate a list, combine a field, or respond to a click, you still have to setState first and then wait for the effect to run.
The dependency array is starting to feel like metaphysics — write it all and you get infinite loops, miss something and you get stale values.
The root cause is usually just one thing:
useEffect was originally an "escape hatch" React gave you for syncing with external systems, but many people treat it as the command center for all internal component logic.
This article is dedicated to clarifying that boundary.
1. First, remember one judgment criterion
React official documentation has made it very clear:
"If there’s no external system involved, you probably don’t need an Effect."
Here, "external systems" include:
Network requests.
Browser APIs like title, scroll, storage.
DOM subscriptions and third-party libraries.
Timers, WebSocket, event listeners.
Conversely, if you’re only doing these things, you probably don’t need an effect:
Computing a new value from existing state/props.
Responding to user events like clicks, inputs, or form submissions.
Synchronizing two pieces of data inside the component that can already be derived.
Initializing a default value that could be directly passed to useState.
In other words, useEffect is not where "any code that runs outside of render" should go. It’s meant to be a synchronization channel between React and the external world.
2. The most common misuse: putting computed properties in useEffect
Let’s look at a typical piece of code first:
function UserList({ users, keyword }: Props) {
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
useEffect(() => {
const next = users.filter(user =>
user.name.toLowerCase().includes(keyword.toLowerCase())
);
setFilteredUsers(next);
}, [users, keyword]);
return (
<ul>
{filteredUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Many people glance at it and think it’s fine. Input changes, effect re-filters, and stores the result in state.
But this is actually one of the patterns React official documentation most strongly discourages, because it turns a pure computation into a two-phase flow: "render → effect → setState → re-render."
Three problems:
filteredUsers can be derived entirely from users and keyword — it’s not independent state.
It always causes an extra render, making the code slower and more convoluted.
If some other code can also modify filteredUsers, the data can easily become inconsistent.
The refactor is straightforward:
function UserList({ users, keyword }: Props) {
const filteredUsers = useMemo(() => {
return users.filter(user =>
user.name.toLowerCase().includes(keyword.toLowerCase())
);
}, [users, keyword]);
return (
<ul>
{filteredUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}If the filtering itself isn’t heavy, you might not even need useMemo — just compute it directly during render.
const filteredUsers = users.filter(...);The truly important thing here isn’t useMemo, but rather a judgment:
If a value can be computed directly during render, don’t go around computing it again in an effect.
3. The second misuse: using useEffect to respond to user events
Now look at another more subtle but very common pattern in projects:
function Form() {
const [jsonToSubmit, setJsonToSubmit] = useState<string | null>(null);
useEffect(() => {
if (!jsonToSubmit) return;
fetch('/api/register', {
method: 'POST',
body: jsonToSubmit,
});
}, [jsonToSubmit]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const json = JSON.stringify({ hello: 'world' });
setJsonToSubmit(json);
}
return <form onSubmit={handleSubmit}>...</form>;
}The thinking behind this kind of code is usually:
Click the submit button.
First put the data to be submitted into state.
Then let an effect listen to that state and make the request.
It looks like "decoupling," but it’s actually taking a detour.
React’s official guidance is clear: if a piece of logic happens because "a user performed an action," it should stay in the event handler, not go into an effect.
A more direct way to write it is:
function Form() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const json = JSON.stringify({ hello: 'world' });
await fetch('/api/register', {
method: 'POST',
body: json,
});
}
return <form onSubmit={handleSubmit}>...</form>;
}Why is this better?
The cause-and-effect relationship is clear: clicking submit triggers the request.
No need to create an intermediate state.
There’s no risk of some other code modifying jsonToSubmit and accidentally triggering a submission.
In a sentence:
Logic that happens "because the component is displayed" is suitable for effects; logic that happens "because the user performed an action" should be prioritized in event handlers.
Once this boundary is established, many effects will automatically disappear.
4. The third misuse: using useEffect to synchronize internal component state
Another classic example:
function Cart({ items }: { items: Item[] }) {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(items.length);
}, [items]);
return <div>{count} items in total</div>;
}The problem here is the same as the filtering example: count is essentially an alias for items.length, not an independent fact.
A more reasonable approach:
function Cart({ items }: { items: Item[] }) {
const count = items.length;
return <div>{count} items in total</div>;
}The biggest problem with this kind of "internal state synchronization" is that it easily grows into chain updates:
items change, an effect updates count.
count changes, another effect updates totalPrice.
totalPrice changes, yet another effect does something else.
Thus, a data relationship that could be expressed in a single line becomes a state machine.
When you see this pattern, you should immediately be alert:
This isn’t state management — it’s turning data computation into side effects.
5. So what should go into useEffect?
After covering what not to do, let’s talk about what to do. Just remember one sentence:
useEffect is for keeping React state in sync with external systems.
Typical scenarios include:
1. Network requests
useEffect(() => {
let ignore = false;
async function load() {
const result = await fetchUser(userId).then(r => r.json());
if (!ignore) setUser(result);
}
load();
return () => {
ignore = true;
};
}, [userId]);Using an effect here is reasonable because a request is an external side effect, and React’s official docs specifically remind you: if you fetch data inside an effect, you need to handle cleanup to avoid race conditions and stale results overwriting newer ones.
2. Subscriptions and cleanup
useEffect(() => {
const unsubscribe = chat.subscribe(roomId, onMessage);
return unsubscribe;
}, [roomId]);This kind of code fits perfectly in an effect because "connecting" and "disconnecting" are naturally paired operations.
3. Browser or third-party APIs
useEffect(() => {
document.title = `共 ${count} 条消息`;
}, [count]);Or:
useEffect(() => {
const observer = new IntersectionObserver(handleIntersect);
observer.observe(ref.current!);
return () => observer.disconnect();
}, []);Here you are synchronizing with the DOM / browser capabilities, not computing data inside the component, so the effect is justified.
6. When should you use useLayoutEffect instead of useEffect?
In most cases, just use useEffect by default.
useLayoutEffect is only suitable for a relatively specific situation:
You need to read layout information (like width, height) or immediately modify the DOM before the browser paints.
If you wait for
useEffectto run, the user might see flickering or misplacement.
For example:
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
setTooltipPosition(calcPosition(rect));
}, []);Kent C. Dodds’ advice is straightforward: 99% of the time use useEffect; only when you need layout measurement or synchronous DOM appearance changes should you consider useLayoutEffect.
So a team rule can be simply:
Default to
useEffect.Only use
useLayoutEffectfor "measuring layout / pre-paint DOM corrections."
7. A complete refactoring example: from a pile of effects to a clear data flow
Look at a piece of bad code closer to real projects:
function ProductTable({ products, category }: Props) {
const [filtered, setFiltered] = useState<Product[]>([]);
const [sorted, setSorted] = useState<Product[]>([]);
const [count, setCount] = useState(0);
useEffect(() => {
setFiltered(
products.filter(p => p.category === category)
);
}, [products, category]);
useEffect(() => {
setSorted(
[...filtered].sort((a, b) => a.price - b.price)
);
}, [filtered]);
useEffect(() => {
setCount(sorted.length);
}, [sorted]);
return (
<>
<div>Count: {count}</div>
<ul>
{sorted.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</>
);
}The scariest part about this code is not that it’s "wrong," but that it "looks well-organized." Each effect does one thing, but as a whole it’s purely side-effect-driven data flow.
After refactoring:
function ProductTable({ products, category }: Props) {
const filtered = useMemo(() => {
return products.filter(p => p.category === category);
}, [products, category]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => a.price - b.price);
}, [filtered]);
const count = sorted.length;
return (
<>
<div>Count: {count}</div>
<ul>
{sorted.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</>
);
}After this refactoring, there are several clear benefits:
Data relationships are explicit:
products -> filtered -> sorted -> count.No redundant render chain.
No risk of effect dependency array mistakes causing partial failures.
The component reads like a "data transformation" rather than a "side effect flowchart."
You’ll notice that many so-called "Hooks best practices" ultimately boil down to the same direction: make components more like pure functions, and let effects only handle what pure functions can’t.
8. A ready-to-use useEffect judgment checklist
When writing code, before adding a new useEffect, ask yourself these questions:
Is there an external system involved?
If not, first suspect whether this effect is really needed.
Does this logic happen because "the component is displayed"?
If it's because "the user clicked a button," prioritize putting it in an event handler.
Can this value be computed from existing props/state?
If it can, don’t create a new state, and definitely don’t use effect + setState.
Does this effect really need cleanup?
Subscriptions, listeners, timers, and request race conditions all require cleanup.
If I removed this effect and replaced it with an expression in render or an event handler, would it be more direct?
Most of the time, the answer is: yes.
Follow on Google
Add HeyBinyang as a preferred source on Google
If you'd like to keep finding my updates through Google, you can mark this site as a preferred source and make it easier to spot in relevant reading flows.
SHARE
Share
Share this article.