Tech1 views

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:

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:

Conversely, if you’re only doing these things, you probably don’t need an effect:

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:

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

  1. filteredUsers can be derived entirely from users and keyword — it’s not independent state.

  2. It always causes an extra render, making the code slower and more convoluted.

  3. If some other code can also modify filteredUsers, the data can easily become inconsistent.

The refactor is straightforward:

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

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

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

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:

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

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:

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

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

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

tsx
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

tsx
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

tsx
useEffect(() => {
  document.title = `共 ${count} 条消息`;
}, [count]);

Or:

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

For example:

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

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:

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

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

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:

  1. Is there an external system involved?

    If not, first suspect whether this effect is really needed.

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

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

  4. Does this effect really need cleanup?

    Subscriptions, listeners, timers, and request race conditions all require cleanup.

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

SHARE

Share

Share this article.