Skip to main content

Command Palette

Search for a command to run...

useEffect Done Right — The Hook That Causes More Bugs Than Any Other

Day 4 of 40 — React System Design Series

Updated
10 min readView as Markdown
 useEffect Done Right — The Hook That Causes More Bugs Than Any Other
R

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.


Hey everyone, Richa here! 👋

bug gif

Day 4. And today we're talking about the hook I have a love-hate relationship with.

useEffect.

I've seen it cause infinite loops in production. I've seen it fetch the same data four times. I've seen it fire on every single render because someone forgot the dependency array. I've written all of those bugs myself at some point.

And the frustrating thing? useEffect is not complicated once you understand what it actually is. But if you learn it by copying patterns without understanding the mental model — you will keep writing those bugs.

So today we go deep. Let's fix this properly.


First — what is useEffect actually for?

bug gif

Here's the mental model shift that changes everything:

useEffect is not a lifecycle hook. It is a synchronisation tool.

Most people learn it as "componentDidMount + componentDidUpdate + componentWillUnmount" — the old class component lifecycle. That's how it was taught for years. It's also why people misuse it constantly.

The right way to think about it:

"After every render where these specific values changed — run this code to sync something outside React with the current state."

"Outside React" is the key phrase. useEffect is for things that are outside React's control:

  • Fetching data from an API
  • Setting up a WebSocket connection
  • Adding a DOM event listener
  • Syncing with localStorage
  • Starting a timer or interval
  • Calling a third-party library

If it doesn't involve something outside React, you probably don't need useEffect.


The three forms of useEffect

Form 1 — No dependency array (runs after every render)

useEffect(() => {
  console.log('This runs after EVERY render');
});

Almost never what you want. Every state change, every prop update, every re-render — it fires. Leave this off by accident and you have an infinite loop waiting to happen.


Form 2 — Empty dependency array (runs once, on mount)

useEffect(() => {
  console.log('This runs once, when the component first mounts');
}, []);

This is the closest thing to componentDidMount. Use it for one-time setup: fetching initial data, setting up subscriptions, connecting to a WebSocket.


Form 3 — With dependencies (runs when specific values change)

useEffect(() => {
  console.log('This runs when userId changes');
}, [userId]);

This is the most common and most powerful form. React compares the dependency values after each render. If any of them changed — it runs the effect again.


The cleanup function — and why you almost always need one

Every useEffect can return a cleanup function. React runs it when:

  • The component unmounts
  • Before running the effect again (if the dependencies changed)
useEffect(() => {
  // setup
  const subscription = someAPI.subscribe(userId);

  // cleanup
  return () => {
    subscription.unsubscribe();
  };
}, [userId]);

Without cleanup — you have memory leaks, stale event listeners, and effects that keep running after the component is gone.

Let me show you a real example that bites people constantly:

// ❌ Bug — no cleanup, no abort controller
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));  // ← this can run after unmount!
  }, [userId]);

  return <div>{user?.name}</div>;
}

What happens here: user navigates to /profile/123, component mounts, fetch starts. Before the fetch finishes, user navigates away — component unmounts. The fetch still completes and calls setUser on an unmounted component. You get a React warning and potentially stale data showing up.

// ✅ Fixed — with AbortController
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

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

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name === 'AbortError') return; // expected, ignore it
        console.error(err);
      });

    return () => controller.abort(); // cleanup cancels the fetch
  }, [userId]);

  return <div>{user?.name}</div>;
}

Now when userId changes or the component unmounts — the previous fetch is cancelled before the new one starts. Clean, safe, correct.


The dependency array — what actually goes in it

This is where most bugs come from. The rule is simple but people ignore it:

Every reactive value that the effect uses must be in the dependency array.

A reactive value is anything that can change between renders — props, state, variables derived from props or state.

// ❌ Missing dependency — stale closure bug
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query).then(setResults); // uses query
  }, []); // ← but query is not in deps!

  return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}

This fetches once on mount with the initial query value. If query changes later — the effect never re-runs. The results stay stale. This is a stale closure — the function "closed over" the old query value.

// ✅ Fixed
useEffect(() => {
  fetchResults(query).then(setResults);
}, [query]); // ← query is included

Now every time query changes — the effect re-runs with the new value.

💡 Install the ESLint plugin: eslint-plugin-react-hooks with the exhaustive-deps rule will catch missing dependencies automatically. This is not optional — it should be in every React project. It will save you hours of debugging.

bug gif


The infinite loop — and how to avoid it

The most common useEffect disaster. Let's see exactly how it happens:

// ❌ Infinite loop
function BadComponent() {
  const [data, setData] = useState([]);

  useEffect(() => {
    setData([1, 2, 3]); // updates state
  }, [data]);            // depends on data → triggers re-render → effect runs again → ...
}

Every time the effect runs, it updates data. Since data is in the dependency array, the effect runs again. Forever.

// ❌ Another infinite loop — objects and arrays
function AnotherBadComponent({ config }: { config: object }) {
  const [result, setResult] = useState(null);

  useEffect(() => {
    processConfig(config).then(setResult);
  }, [config]); // ← if config is a new object reference every render, this loops
}

This one is sneaky. Even if config contains the same values, if the parent creates a new object on every render (config={{ theme: 'dark' }}), React sees a new reference and re-runs the effect.

Solutions:

// Option 1 — useMemo to stabilise the reference
const stableConfig = useMemo(() => config, [config.theme, config.size]);

// Option 2 — depend on specific primitive values, not the object
useEffect(() => {
  processConfig(config).then(setResult);
}, [config.theme, config.size]); // primitive values, not the object

A complete, real-world example — data fetching with all the pieces

bug gif

Let me put it all together. Here's a proper data fetching hook with loading, error, and cleanup:

type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function useUserData(userId: string | null) {
  const [state, setState] = useState<FetchState<User>>({ status: 'idle' });

  useEffect(() => {
    if (!userId) {
      setState({ status: 'idle' });
      return;
    }

    const controller = new AbortController();
    setState({ status: 'loading' });

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP error ${res.status}`);
        return res.json();
      })
      .then((data: User) => setState({ status: 'success', data }))
      .catch(err => {
        if (err.name === 'AbortError') return;
        setState({ status: 'error', error: err.message });
      });

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

  return state;
}

And using it in a component:

function UserProfile({ userId }: { userId: string }) {
  const userState = useUserData(userId);

  if (userState.status === 'idle') return null;
  if (userState.status === 'loading') return <Spinner />;
  if (userState.status === 'error') return <ErrorMessage message={userState.error} />;

  return (
    <div>
      <h1>{userState.data.name}</h1>
      <p>{userState.data.email}</p>
    </div>
  );
}

Notice:

  • The async state pattern from Day 3 (idle | loading | success | error) — it pays off here
  • AbortController for cleanup
  • userId in the dependency array — refetches when it changes
  • Guard for null userId — no unnecessary fetch
  • The component itself is clean — just rendering, no effect logic

This is production-quality data fetching with useEffect. You could replace the internals with React Query later and the component wouldn't change at all.


When NOT to use useEffect

This is just as important. useEffect is overused. A lot of code I've seen in the wild uses it for things that don't need it at all.

Don't use useEffect to transform data for rendering:

// ❌ Unnecessary useEffect
function UserList({ users }: { users: User[] }) {
  const [filtered, setFiltered] = useState(users);

  useEffect(() => {
    setFiltered(users.filter(u => u.active));
  }, [users]);

  return <ul>{filtered.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// ✅ Just compute it during render
function UserList({ users }: { users: User[] }) {
  const filtered = users.filter(u => u.active); // no effect needed

  return <ul>{filtered.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

Don't use useEffect to reset state when props change:

// ❌ Unnecessary useEffect
function UserForm({ userId }: { userId: string }) {
  const [name, setName] = useState('');

  useEffect(() => {
    setName(''); // reset form when userId changes
  }, [userId]);

  // ...
}

// ✅ Use the key prop instead — React will remount the component
<UserForm key={userId} userId={userId} />

Don't use useEffect to notify a parent of a state change:

// ❌ Unnecessary useEffect — causes an extra render
function Toggle({ onChange }: { onChange: (val: boolean) => void }) {
  const [on, setOn] = useState(false);

  useEffect(() => {
    onChange(on); // notify parent after render
  }, [on, onChange]);

  // ...
}

// ✅ Call onChange directly in the event handler
function Toggle({ onChange }: { onChange: (val: boolean) => void }) {
  const [on, setOn] = useState(false);

  function handleClick() {
    const newVal = !on;
    setOn(newVal);
    onChange(newVal); // one render, not two
  }

  // ...
}

Quick decision guide — do I need useEffect?

Ask yourself:

  1. Does this code touch something outside React? (API, DOM, timer, WebSocket, localStorage) → useEffect is appropriate
  2. Am I computing something from existing state/props? → Just compute it in the render function, no effect needed
  3. Am I syncing state between two state variables? → Usually a sign the state should be one thing, not two
  4. Am I responding to an event? → Put it in the event handler, not an effect

If you answer "no" to question 1 — you probably don't need useEffect.


Quick summary

Pattern Right? Why
useEffect with no dep array ❌ Almost never Runs after every render
useEffect(fn, []) for one-time setup ✅ Yes Mount only, clean pattern
useEffect(fn, [dep]) ✅ Yes Re-runs when dep changes
No cleanup for subscriptions/fetch ❌ Memory leak Always add cleanup
Missing dependency in array ❌ Stale closure Add it, use ESLint rule
Effect to compute derived data ❌ Overkill Compute in render
Effect to respond to events ❌ Wrong tool Use event handlers

Today's takeaway

bug gif

useEffect is not complicated — but it has a precise mental model.

It's a synchronisation tool for keeping things outside React in sync with your component's state. If you're not syncing something external — you probably don't need it.

The three rules to avoid every major bug:

  1. Always add cleanup when you set up anything that needs to be torn down
  2. Always include all reactive values in the dependency array (use the ESLint rule)
  3. Don't reach for useEffect when you can just compute during render or handle in an event handler

Once these click — the infinite loops and stale data bugs stop. Completely.

finally gif

See you tomorrow for Day 5 — useMemo and useCallback: when memoisation actually helps (and when it's just noise).


React System Design — Learning in Public

Part 4 of 36

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js — and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing — the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

useMemo, useCallback, React.memo — The Real Rules for When to Use Them

Day 5 of 40 — React System Design Series