Common React Hook Anti‑Patterns That Trip Up 80% of Teams

The article examines why many React projects become unstable due to misuse of hooks, presenting seven frequent anti‑patterns with concrete bad and good code examples, explaining the underlying problems, and offering practical guidelines and a checklist to write reliable Hook‑based effects.

CodeNotes
CodeNotes
CodeNotes
Common React Hook Anti‑Patterns That Trip Up 80% of Teams

Introduction

Many React projects are not missing features but are unstable: duplicate requests, data corruption, errors after navigation, and occasional production crashes. The root cause is often how Hooks are used.

Hooks are difficult not because of syntax but because of three boundaries: where side‑effects belong, how dependencies are declared, and how to clean up when the component’s lifecycle changes.

This article does not cover the full API; it focuses on the seven most common anti‑patterns, providing the wrong code, the correct code, and the reasoning behind each.

Anti‑Pattern 1: Using useEffect as a “catch‑all initializer”

Common Symptoms

An effect that fetches data, binds events, writes to localStorage, reports analytics, and manipulates the DOM all at once, causing every dependency change to re‑run all logic.

Wrong Code

useEffect(() => {
  fetchUser();

  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', onResize);

  localStorage.setItem('lastPage', 'profile');
  reportPV('profile');

  return () => {
    window.removeEventListener('resize', onResize);
  };
}, [userId, theme]);

Correct Code

useEffect(() => {
  fetchUser();
}, [userId]);

useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

useEffect(() => {
  reportPV('profile');
}, []);

useEffect(() => {
  localStorage.setItem('theme', theme);
}, [theme]);

Why

An effect should serve a single “source of change”. Mixing multiple sources leads to polluted trigger conditions, hard‑to‑maintain cleanup logic, and developers cannot tell why the effect re‑runs.

Guidelines

Each effect should handle only one class of side‑effect.

Every effect’s dependency list should be explainable in a single sentence.

Anti‑Pattern 2: Filling the dependency array by feel

Common Symptoms

Intentionally omitting dependencies to avoid re‑runs, or over‑including whole objects, which either cause stale closures or excessive recomputation.

Wrong Code A – Missing Dependency

useEffect(() => {
  if (keyword) {
    search(keyword, page);
  }
}, [keyword]); // missing page

Wrong Code B – Object Dependency Causing Frequent Triggers

useEffect(() => {
  fetchList(filters);
}, [filters]); // filters may be a new reference each render

Correct Code

useEffect(() => {
  if (!keyword) return;
  search(keyword, page);
}, [keyword, page]);

const stableFilters = useMemo(
  () => ({ status, ownerId, sortBy }),
  [status, ownerId, sortBy]
);

useEffect(() => {
  fetchList(stableFilters);
}, [stableFilters]);

Why

The dependency array is a contract between the side‑effect and the data flow, not a performance toggle. Missing dependencies read stale values; extra dependencies cause unnecessary effect executions.

Guidelines

Follow eslint‑plugin‑react‑hooks by default.

If an exception is needed, document the reason in a comment.

Anti‑Pattern 3: Declaring the effect callback as async

Common Symptoms

Using useEffect(async () => { … }) appears to work but returns a Promise, breaking React’s expectation that the return value is a cleanup function.

Wrong Code

useEffect(async () => {
  setLoading(true);
  const data = await api.getUser(userId);
  setUser(data);
  setLoading(false);
}, [userId]);

Correct Code

useEffect(() => {
  let alive = true;

  const run = async () => {
    try {
      setLoading(true);
      const data = await api.getUser(userId);
      if (alive) {
        setUser(data);
      }
    } finally {
      if (alive) {
        setLoading(false);
      }
    }
  };

  run();

  return () => {
    alive = false;
  };
}, [userId]);

Why

React expects the effect to return a cleanup function, not a Promise. Moreover, the async request may finish after the component has unmounted; without an “alive” guard it would set state on an unmounted component.

Guidelines

Declare and invoke an async function inside the effect instead of making the effect itself async.

Perform alive checks in finally before calling setState.

Anti‑Pattern 4: Ignoring race conditions (later request wins)

Common Symptoms

Rapidly changing filter criteria causes an earlier request to finish later and overwrite the newer result, producing a “flash‑back”.

Wrong Code

useEffect(() => {
  const load = async () => {
    const res = await api.search(keyword);
    setList(res.items);
  };
  load();
}, [keyword]);

Correct Code (AbortController)

useEffect(() => {
  const controller = new AbortController();

  const load = async () => {
    try {
      const res = await api.search(keyword, { signal: controller.signal });
      setList(res.items);
    } catch (error) {
      if ((error).name !== 'AbortError') {
        setError('加载失败');
      }
    }
  };

  if (keyword) {
    load();
  } else {
    setList([]);
  }

  return () => {
    controller.abort();
  };
}, [keyword]);

Why

Race conditions arise because multiple async tasks run concurrently and their return order is nondeterministic. Canceling the previous request or validating a sequence number ensures that the last input corresponds to the last displayed result.

Guidelines

All list‑search requests should support cancellation by default.

Standardize the API layer to accept an AbortSignal, avoiding duplicated boilerplate.

Anti‑Pattern 5: Updating state after component unmount

Common Symptoms

Console warning: “Can’t perform a React state update on an unmounted component”.

Wrong Code

useEffect(() => {
  let timer = setTimeout(async () => {
    const detail = await api.getDetail(id);
    setDetail(detail);
  }, 500);

  return () => {
    clearTimeout(timer);
  };
}, [id]);

Clearing the timer does not stop a request that has already been sent; it may still write state after unmount.

Correct Code

useEffect(() => {
  const controller = new AbortController();
  let alive = true;

  const timer = window.setTimeout(async () => {
    try {
      const detail = await api.getDetail(id, { signal: controller.signal });
      if (alive) {
        setDetail(detail);
      }
    } catch (error) {
      if ((error).name !== 'AbortError' && alive) {
        setError('获取详情失败');
      }
    }
  }, 500);

  return () => {
    alive = false;
    controller.abort();
    clearTimeout(timer);
  };
}, [id]);

Why

Cleaning synchronous resources (e.g., timers) is different from aborting asynchronous tasks. Both need to be handled, and state writes must be guarded by an alive check.

Guidelines

Cleanup should handle at least three things: unbinding, canceling, and preventing stale writes.

Anti‑Pattern 6: Putting pure calculations inside an effect, causing a second render

Common Symptoms

Initial render, then an effect sets state that triggers a second render, leading to visible flicker and higher latency.

Wrong Code

const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${user.firstName} ${user.lastName}`);
}, [user.firstName, user.lastName]);

Correct Code

const fullName = useMemo(
  () => `${user.firstName} ${user.lastName}`,
  [user.firstName, user.lastName]
);

If the computation is trivial, it can be inlined directly:

const fullName = `${user.firstName} ${user.lastName}`;

Why

Effects are for side‑effects, not for deriving state. Values that can be synchronously derived from existing state/props should be computed directly or memoized, not stored via setState.

Guidelines

Ask: can this value be computed from existing data?

If it can be computed, do not store it in state; reduce state count.

Anti‑Pattern 7: Event‑callback closures capture stale values

Common Symptoms

Intervals or event listeners keep printing an old state value; updates are not reflected inside the callback.

Wrong Code

const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1); // always uses the initial count
  }, 1000);

  return () => clearInterval(timer);
}, []);

Correct Code A – Functional Update

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1);
  }, 1000);

  return () => clearInterval(timer);
}, []);

Correct Code B – Store latest value in a ref (for complex callbacks)

const latestCountRef = useRef(count);

useEffect(() => {
  latestCountRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    console.log(latestCountRef.current);
  }, 1000);

  return () => clearInterval(timer);
}, []);

Why

Closures capture the variable snapshot at creation time and do not automatically point to the latest value. Functional updates and refs avoid this snapshot problem.

Guidelines

Use functional updates for state changes inside timers.

Use refs when long‑lived callbacks need to read the latest value.

Practical Hook Self‑Check Checklist

Insert the following checklist into a code‑review template for immediate impact:

Does each effect have a single, clear responsibility?

Is the dependency array complete and explainable?

Is there any useEffect(async () => {}) usage?

Are asynchronous requests cancellable (AbortController)?

Does cleanup handle unbinding, cancellation, and preventing stale writes?

Is derived state mistakenly stored in state?

Can timer or listener callbacks read stale values?

Are lint rules disabled without an explanatory comment?

Team‑Level Hook Conventions (Ready to Copy)

- Prohibit useEffect(async () => {})
- Effects should follow exhaustive‑deps by default
- Any request‑type effect must be cancellable
- Never call setState unconditionally in finally blocks
- Do not store derived values in state unless there is a clear caching benefit
- Use refs for long‑lived callbacks that need the latest value

If you are a technical lead, try these six rules in a single module for two weeks before rolling them out project‑wide.

Conclusion

Hooks are not hard because you can’t write them; they are hard because you must respect engineering discipline.

When you solidify the three boundaries—side‑effect scope, dependency contract, and async cleanup—React project stability improves dramatically.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

FrontendReActHooksuseEffectanti-pattern
CodeNotes
Written by

CodeNotes

Discuss code and AI, and document daily life and personal growth.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.