Skip to content

React7 min read

useEffect Mistakes: When You Don't Need an Effect in React

Common useEffect mistakes and when you don't need an effect at all: derived state, resetting state, fetch races, subscriptions and useEffectEvent, with fixes.

Most useEffect bugs come from using an Effect for work that is not synchronization: calculating values from props or state, reacting to a button click, or resetting state when a prop changes. None of those need an Effect. You only need useEffect to keep a component in sync with something outside React, like a WebSocket, a browser API, a timer or a third-party widget. Below are the mistakes I see most in code reviews, each with the fix.

The React team's guide You Might Not Need an Effect is the source for most of these patterns. This post is the practical version, in TypeScript, with React 19.3 in mind.

Key takeaways

  • If a value can be calculated from props or state, calculate it during render. Do not store it in state and sync it with an Effect.
  • Logic caused by a user action belongs in the event handler, not in an Effect that watches state.
  • To reset all state when a prop changes, change the component's key.
  • Every Effect that starts something (a request, a subscription, a timer) needs cleanup.
  • Use useSyncExternalStore for external subscriptions and useEffectEvent (React 19.2+) for "read latest value without re-running".
  • The react-hooks/set-state-in-effect lint rule catches a lot of this automatically.

The one question to ask before writing useEffect

Is this code running because the component is displayed, or because the user did something?

If it runs because the component is on screen (connect to a chat room, start a timer, sync a map widget), an Effect fits. If it runs because of a click, submit or keypress, put it in the handler. If it runs because "some state changed and I need another value", it is probably not an Effect at all.

What you want Use Not
Value computed from props/state Plain variable during render (useMemo if expensive) useEffect + setState
Respond to a click or submit Event handler useEffect watching a flag
Reset state when an ID changes key prop useEffect that calls setState
Subscribe to browser or store data useSyncExternalStore useEffect + useState
Fetch data A data library or framework loader; Effect with cleanup as fallback Effect without cleanup
Connect to an external system useEffect with cleanup Code in the render body

Mistake 1: Storing derived state

TSX
// Avoid: extra state + Effect
function Cart({ items }: { items: { price: number; qty: number }[] }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
  }, [items]);

  return <p>Total: {total}</p>;
}

This renders twice on every change: once with the stale total, then again after the Effect. It also creates a second source of truth that can drift.

TSX
// Better: calculate during render
function Cart({ items }: { items: { price: number; qty: number }[] }) {
  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  return <p>Total: {total}</p>;
}

If the calculation is genuinely expensive, wrap it in useMemo, or let the React Compiler memoize it for you. I cover when manual memoization is still worth it in React Compiler: do you still need useMemo and useCallback?.

Mistake 2: Running event logic in an Effect

TSX
// Avoid: Effect fires because state changed, not because user acted
function CheckoutButton({ productId }: { productId: string }) {
  const [submitted, setSubmitted] = useState(false);

  useEffect(() => {
    if (submitted) {
      fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ productId }) });
      showToast('Order placed');
    }
  }, [submitted, productId]);

  return <button onClick={() => setSubmitted(true)}>Buy</button>;
}

This breaks in subtle ways. If productId changes while submitted is true, the order is posted again. In development StrictMode, the Effect can run twice on remount.

TSX
// Better: do it where it happens
function CheckoutButton({ productId }: { productId: string }) {
  async function handleClick() {
    await fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ productId }) });
    showToast('Order placed');
  }

  return <button onClick={handleClick}>Buy</button>;
}

In React 19 you can also pass an async function to a form's action and let useActionState handle the pending state. See React 19 hooks explained.

Mistake 3: Resetting state when a prop changes

TSX
// Avoid
function ProfilePage({ userId }: { userId: string }) {
  const [comment, setComment] = useState('');

  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

For one render the old comment shows on the new profile, then it clears. With nested components holding their own state, it gets worse.

TSX
// Better: give each user a separate component instance
function ProfilePage({ userId }: { userId: string }) {
  return <Profile key={userId} userId={userId} />;
}

function Profile({ userId }: { userId: string }) {
  const [comment, setComment] = useState('');
  // all state here resets automatically when userId changes
  // ...
}

When key changes, React treats it as a new component and resets all state inside it.

If you only need to adjust part of the state, prefer deriving it. For example, store selectedId instead of the selected object, then find the item during render. If the ID no longer exists, the selection is naturally null.

Mistake 4: Chains of Effects

TSX
// Avoid: each Effect triggers the next render
useEffect(() => { if (card?.gold) setGoldCount((c) => c + 1); }, [card]);
useEffect(() => { if (goldCount > 3) { setRound((r) => r + 1); setGoldCount(0); } }, [goldCount]);
useEffect(() => { if (round > 5) setIsGameOver(true); }, [round]);

Each step causes another render, and the logic is spread across three places. Move it into the handler that starts the chain and compute what you can during render:

TSX
const isGameOver = round > 5;

function handlePlaceCard(nextCard: Card) {
  if (isGameOver) throw new Error('Game already ended.');

  setCard(nextCard);
  if (nextCard.gold) {
    if (goldCount < 3) {
      setGoldCount(goldCount + 1);
    } else {
      setGoldCount(0);
      setRound(round + 1);
    }
  }
}

Mistake 5: Fetching without cleanup (race conditions)

TSX
// Avoid: the slower response can win
useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then((r) => r.json())
    .then(setResults);
}, [query]);

If the user types "re" then "react", and the "re" request returns last, the UI shows results for "re". Abort the old request in cleanup:

TSX
import { useEffect, useState } from 'react';

type Result = { id: string; title: string };

export function Search({ query }: { query: string }) {
  const [results, setResults] = useState<Result[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!query) return;
    const controller = new AbortController();

    fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json() as Promise<Result[]>;
      })
      .then((data) => {
        setResults(data);
        setError(null);
      })
      .catch((err: unknown) => {
        if (err instanceof DOMException && err.name === 'AbortError') return;
        setError('Search failed');
      });

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

  return (
    <>
      {error && <p role="alert">{error}</p>}
      <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
    </>
  );
}

That works, but you still have no caching, no deduplication and no retry. For anything beyond a demo, use a data library or your framework's data loading. My TanStack Query guide shows the same search with a few lines of code.

Mistake 6: Objects and functions in the dependency array

TSX
// Avoid: options is a new object every render, so the Effect re-runs every render
const options = { roomId, serverUrl };
useEffect(() => {
  const conn = createConnection(options);
  conn.connect();
  return () => conn.disconnect();
}, [options]);

Create the object inside the Effect and depend on primitives:

TSX
useEffect(() => {
  const conn = createConnection({ roomId, serverUrl });
  conn.connect();
  return () => conn.disconnect();
}, [roomId, serverUrl]);

Never silence react-hooks/exhaustive-deps to "fix" a loop. The lint is telling you the Effect reads something it does not declare. Change the code so the dependency is not needed.

Mistake 7: Hand-rolled subscriptions

TSX
// Avoid: can tear and misses updates between render and Effect
const [online, setOnline] = useState(true);
useEffect(() => {
  const update = () => setOnline(navigator.onLine);
  window.addEventListener('online', update);
  window.addEventListener('offline', update);
  return () => {
    window.removeEventListener('online', update);
    window.removeEventListener('offline', update);
  };
}, []);

React has a hook built for this:

TSX
import { useSyncExternalStore } from 'react';

function subscribe(callback: () => void) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine, // client snapshot
    () => true,             // server snapshot
  );
}

subscribe is defined outside the component so its identity is stable. I use this same pattern for media queries in 10 custom React hooks worth having.

Mistake 8: Re-running an Effect just to read the latest value

A classic case: you connect to a chat room and want to show a notification using the current theme. Adding theme to the dependencies reconnects every time the theme changes, which is wrong. Leaving it out is a stale closure.

useEffectEvent, stable since React 19.2, splits the non-reactive part out:

TSX
import { useEffect, useEffectEvent } from 'react';

export function ChatRoom({ roomId, theme }: { roomId: string; theme: 'light' | 'dark' }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Welcome to {roomId}</h1>;
}

The rules from the docs: only call an Effect Event from inside Effects (or other Effect Events), never during render, never pass it to other components, and do not use it to dodge real dependencies.

Why does my Effect run twice?

In development, <StrictMode> mounts each component, unmounts it, and mounts it again. It exists to expose Effects that start something and never clean it up. If your Effect breaks when it runs twice, the bug is the missing cleanup, and users would hit it too when navigating back and forth. Production builds do not double-run.

Let the linter find these for you

eslint-plugin-react-hooks 7.x ships compiler-powered rules in its recommended preset, including react-hooks/set-state-in-effect and react-hooks/set-state-in-render. Turning them on in an existing codebase is a fast way to find most of the mistakes above.

JavaScript
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';

export default defineConfig([reactHooks.configs.flat.recommended]);

FAQ

When should I use useEffect in React?

Use an Effect to synchronize a component with something outside React, such as a network connection, a browser API, a third-party widget or a timer. If the code only transforms props or state, or responds to a specific user action, it does not need an Effect.

Why does my useEffect run twice?

In development with StrictMode, React mounts, unmounts and remounts components once to help you find Effects that are missing cleanup. It does not happen in production. Fix the cleanup rather than removing StrictMode.

How do I stop a useEffect infinite loop?

An infinite loop usually means the Effect sets state that is also in its dependency array, or depends on an object or function recreated every render. Derive the value during render instead, or depend on primitive values.

What is useEffectEvent?

useEffectEvent, stable since React 19.2, wraps logic that should read the latest props and state from inside an Effect without making the Effect re-run when those values change.

Wrapping up

Before writing useEffect, ask whether you are syncing with something outside React. If not, compute during render, move the logic into a handler, or reach for key. When you do need an Effect, give it a cleanup and honest dependencies. Your components get shorter and the "why did this run?" bugs mostly disappear.

  • #react
  • #hooks
  • #useeffect
  • #best-practices