Tech0 views

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—

In this article, we won't dive into any deep principles; we'll just break down three questions:

  1. Why does "stuffing everything into useEffect" make components increasingly hard to maintain?

  2. Why does logic start to warp once "values that can be computed" become state?

  3. 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":

tsx
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:

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:

  1. The user's current keyword.

  2. The original product list returned from the backend.

Then:

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:

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:

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:

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:

Putting pure computations into effects incurs these extra costs:

  1. Performance:

    • Every time you update filtered/total, you go through "render → run effect → setState → re-render" two rounds.

  2. 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."

  3. 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":

4. Why are closures especially tricky in Hooks?

Look at another simplified example:

tsx
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:

This is the classic "closure + dishonest dependency array" problem:

This problem is especially common in the Hooks era because:

Even worse:

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:

  1. Only create state for the "minimum non-derivable" things: keyword, items, loading, etc.

  2. All derivable values (filtered, total) are implemented as computed properties, not an additional state + effect.

  3. useEffect only handles things that interact with external systems, like fetching data.

A cleaner version is:

tsx
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:

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:

  1. 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.

  2. 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?

  3. Are there any useEffect dependency arrays that are obviously "missing" items, just to avoid ESLint warnings?

  4. 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.

  5. Are there tricky bugs where "console.log shows the old value"?

    • Such issues are almost always related to closures + incomplete dependency arrays.

SHARE

Share

Share this article.