Why React Hooks Get Messy Over Time (and How to Fix It)

Many people who first get started with React Hooks go through a similar mental journey: at first, they think it's great—function components with useState/useEffect feel much cleaner than class components.
After a few months, looking back at previous code, they find a bunch of weird issues—
A huge blob of useEffect in a component, and modifying anything feels like it will break everything.
Not many states, yet logic is scattered across various effects, refs, and callbacks.
Occasionally encountering those closure issues where "console.log shows the old value" or "behavior is erratic during debugging."
In this article, we won't dive into any deep principles; we'll just break down three questions:
Why does "stuffing everything into useEffect" make components increasingly hard to maintain?
Why does logic start to warp once "values that can be computed" become state?
Why are closures especially tricky in Hooks?
Once we recognize these bad smells, we'll follow up with separate articles on useEffect boundaries, closure practices, computed properties, and more.
1. What does a typical "works but is hard to maintain" component look like?
Let's first look at some code that "looks similar in many projects":
function ProductList({ initialKeyword }: { initialKeyword: string }) {
const [keyword, setKeyword] = useState(initialKeyword);
const [items, setItems] = useState<Product[]>([]);
const [filtered, setFiltered] = useState<Product[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
// Initialize data fetch
useEffect(() => {
setLoading(true);
fetch(`/api/products?keyword=${keyword}`)
.then(res => res.json())
.then(data => {
setItems(data.items);
setTotal(data.total);
})
.finally(() => setLoading(false));
}, [keyword]);
// Filter local list based on keyword (a bit redundant, but often seen in real projects)
useEffect(() => {
const next = items.filter(item =>
item.name.toLowerCase().includes(keyword.toLowerCase())
);
setFiltered(next);
}, [keyword, items]);
// Update total when filtered changes
useEffect(() => {
setTotal(filtered.length);
}, [filtered]);
// Listen for initialKeyword changes (e.g., route switch in parent)
useEffect(() => {
setKeyword(initialKeyword);
}, [initialKeyword]);
if (loading) return <div>Loading...</div>;
return (
<div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div>{total} products total</div>
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}This code has several typical characteristics:
Lots of states: keyword, items, filtered, total, loading.
Lots of effects: initialization request, filtering, counting, syncing props.
It looks like "clear separation of concerns, each effect does one thing," but in reality there are quite a few problems.
You might say: it works, looks okay, what's the problem?
The problem is: In this code, many states shouldn't exist, and many effects shouldn't exist either.
2. Does more state necessarily mean more complex logic?
Let's reconsider what this component "truly needs to know" from a data perspective:
The user's current keyword.
The original product list returned from the backend.
Then:
filtered can be fully computed from items + keyword.
total can be fully computed from filtered.length (or items.length).
In other words:
filtered and total are both standard "derived states"—essentially computed properties, not original states.
Once you put these derivable values into state:
You become responsible for "keeping these states always consistent."
If any path forgets to sync, you get creepy bugs like "the displayed total is wrong" or "the filter result doesn't match the data."
The official React documentation repeatedly emphasizes: When a value can be computed from props or state, do not maintain a separate state for it.
Now looking back at this code, you'll find:
We added state for filtered and total for "convenience."
Then, to make them "look synchronized," we wrote two effects to maintain them.
It's equivalent to: a problem that could be solved with one formula has been split into three states plus two side effects.
3. What happens when useEffect is used as a "logic trash can"?
That component above has another issue: Almost all logic is "broken into pieces" and stuffed into useEffect:
One effect for fetching data.
One effect for filtering.
One effect for counting.
One effect for syncing props.
The thing is, the original purpose of useEffect is:
To synchronize React state with some "external system," such as requests, subscriptions, or DOM operations.
Filtering and counting are pure computations that can be done during render:
No DOM manipulation needed.
No browser API access needed.
No network requests needed.
Putting pure computations into effects incurs these extra costs:
Performance:
Every time you update filtered/total, you go through "render → run effect → setState → re-render" two rounds.
Mental load:
Logic is scattered across multiple effects; modifying a behavior requires searching everywhere.
It's hard to tell at a glance "how this UI state is computed."
Bug risk:
During a refactor, you might miss a variable in the dependency array, and the effect stops updating as expected;
Or you haphazardly remove dependencies to avoid "infinite loops," tuning until "it doesn't error out."
Over time, this component degenerates from "function + Hooks" into "class lifecycle written with functions":
Lifecycle methods turn from componentDidMount/componentDidUpdate into a pile of useEffects.
The same problems exist, and are even more insidious.
4. Why are closures especially tricky in Hooks?
Look at another simplified example:
function SearchBox() {
const [keyword, setKeyword] = useState('');
useEffect(() => {
const id = setInterval(() => {
console.log('search with', keyword);
// fetch(`/api/search?keyword=${keyword}`)
}, 5000);
return () => clearInterval(id);
}, []); // Note: dependency array is []
return (
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
);
}The problem is very typical:
You think the timer "does a search every 5 seconds with the latest keyword."
In reality, the effect only runs once on mount, and the closure captures the keyword from the initial render.
No matter what the user types, it always logs an empty string.
This is the classic "closure + dishonest dependency array" problem:
Callbacks created inside useEffect "capture" the props and state at that time.
If the dependency array doesn't include keyword, the effect won't re-run when keyword updates.
The callback always references the old variable.
This problem is especially common in the Hooks era because:
In class components, you used this.state, which is always current;
In function components, you use closure variables, whose versions follow render snapshots.
Even worse:
Many people, to avoid ESLint warnings, directly change the dependency array to [] or arbitrarily remove dependencies.
That's equivalent to explicitly telling React: "I know this uses keyword, but don't worry about it changing."
The result is:
The code appears "quiet" on the surface—no warnings, no errors—but the behavior is wrong.
Debugging this kind of issue is especially time-consuming—console.log can show "the value is correct," but the callback inside the effect still has the old value.
5. What should we really learn from this example?
Going back to our initial ProductList example, if we rewrite it following a few principles recommended by React, it would look like this:
Only create state for the "minimum non-derivable" things: keyword, items, loading, etc.
All derivable values (filtered, total) are implemented as computed properties, not an additional state + effect.
useEffect only handles things that interact with external systems, like fetching data.
A cleaner version is:
function ProductList({ initialKeyword }: { initialKeyword: string }) {
const [keyword, setKeyword] = useState(initialKeyword);
const [items, setItems] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/products?keyword=${keyword}`)
.then(res => res.json())
.then(data => {
if (cancelled) return;
setItems(data.items);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [keyword]);
const filtered = useMemo(
() =>
items.filter(item =>
item.name.toLowerCase().includes(keyword.toLowerCase())
),
[items, keyword]
);
const total = filtered.length;
useEffect(() => {
setKeyword(initialKeyword);
}, [initialKeyword]);
if (loading) return <div>Loading...</div>;
return (
<div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div>{total} products total</div>
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}You can see:
useEffect is reduced to two: one for fetching data, one for syncing initialKeyword.
The "data relationships" within the component are clear at a glance: items → filtered → total.
The number of states has decreased, and the logic becomes clearer.
You can even optimize further: if initialKeyword is only used once on mount and won't change later, you can eliminate the second effect entirely by using route keys or parent component control.
6. A "code review checklist" you can take away from this article
In this first article, we won't rush into specific techniques; we'll just establish a few judgment criteria. You can directly use them to review the Hooks code in your own projects:
How many states does this component have? Is each state "minimum non-derivable"?
If a field can be fully computed from other states/props, it's redundant.
How many useEffects does this component have?
Are they all doing synchronization with the "external world"?
Are there any pure computaion logic stuffed into an effect?
Are there any useEffect dependency arrays that are obviously "missing" items, just to avoid ESLint warnings?
Is there a chain of "states driving each other": A changes → effect changes B, B changes → effect changes C?
This kind of chained update often means that B and C are actually computable.
Are there tricky bugs where "console.log shows the old value"?
Such issues are almost always related to closures + incomplete dependency arrays.
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.