Senior 5 min · March 05, 2026

React useEffect Stale Closure — Cart Deleted on Navigation

Shopping cart cleared on page navigation due to useEffect stale closure over empty array.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • useState stores values outside render so they survive re-renders; calling the setter schedules a future render, it doesn't mutate the variable.
  • useEffect runs after the browser paints — never use it to compute values for the current render; use inline calculation or useMemo.
  • The dependency array is a watch list: empty [] = run once; missing array = run every render; array with values = run when those change.
  • Always return a cleanup function from any effect that sets up a timer, subscription, or fetch — missing cleanup leads to memory leaks and stale state updates.
Plain-English First

Imagine your React component is a whiteboard in a classroom. useState is the marker — it lets you write something on the board and erase it when things change. useEffect is the teacher who walks in after every change and says 'OK, something changed — let me react to that.' Without useState, the board never updates. Without useEffect, nobody notices when it does.

Every interactive UI you've ever used — a loading spinner, a live search bar, a tweet that updates its like count without a page refresh — relies on two things: state that changes over time, and side effects that respond to those changes. React's useState and useEffect hooks are the engine behind all of it. They're not just syntax sugar. They represent a fundamental shift in how we think about building UIs: as a function of state, not as a series of DOM mutations.

Before hooks arrived in React 16.8, you had to write class components just to store a piece of state or fire an API call after render. That meant lifecycle methods like componentDidMount and componentDidUpdate scattered across large files, making logic hard to co-locate and even harder to reuse. Hooks solved this by letting you drop stateful logic directly into a function component — keeping related code together and making it trivially portable as a custom hook.

By the end of this article you'll understand not just how to call useState and useEffect, but when each one is the right tool, what happens under the hood on each render, and the three most common bugs developers write with these hooks — plus exactly how to fix them. You'll also walk away with real interview-ready answers that go beyond reciting the docs.

useState — How React Remembers Things Between Renders

Every time React re-renders a component, it calls that function again from scratch. Local variables get thrown away. So how does a counter remember its count? That's the problem useState solves.

When you call useState(initialValue), React stores that value outside your component in a special internal cell tied to that specific hook call position. On the next render, instead of re-initialising from scratch, React hands you back whatever value is currently in that cell. You also get a setter function — when you call it, React schedules a re-render and updates the cell before the next one runs.

This is why state updates feel asynchronous. You're not mutating a variable. You're scheduling a future render with new data. That distinction is critical — it explains several gotchas we'll cover later.

Use useState whenever a piece of data needs to trigger a visual update when it changes. If a value changes but you don't need the UI to re-render, a plain ref (useRef) is usually the better tool.

PasswordStrengthChecker.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { useState } from 'react';

// A real-world example: a password strength indicator
function PasswordStrengthChecker() {
  // useState returns [currentValue, setterFunction]
  // React stores 'password' outside this function so it survives re-renders
  const [password, setPassword] = useState('');

  // Derived state — calculated fresh each render from 'password'
  // No need for a separate useState here; it's not independently settable
  const strength = calculateStrength(password);

  function handleChange(event) {
    // We pass the new value to the setter — React schedules a re-render
    // After re-render, 'password' will equal event.target.value
    setPassword(event.target.value);
  }

  return (
    <div>
      <input
        type="password"
        value={password}          // Controlled input — React owns this value
        onChange={handleChange}
        placeholder="Enter password"
      />
      <p>Strength: <strong>{strength.label}</strong></p>
      <div
        style={{
          width: `${strength.score * 25}%`, // Score 0-4, converts to 0-100%
          height: '8px',
          backgroundColor: strength.color,
          transition: 'width 0.3s ease'
        }}
      />
    </div>
  );
}

function calculateStrength(password) {
  let score = 0;
  if (password.length >= 8)          score++; // Minimum length
  if (/[A-Z]/.test(password))        score++; // Has uppercase
  if (/[0-9]/.test(password))        score++; // Has number
  if (/[^A-Za-z0-9]/.test(password)) score++; // Has special char

  const levels = [
    { label: 'Too short',  color: '#e74c3c' },
    { label: 'Weak',       color: '#e67e22' },
    { label: 'Fair',       color: '#f1c40f' },
    { label: 'Good',       color: '#2ecc71' },
    { label: 'Strong',     color: '#27ae60' },
  ];

  return { score, ...levels[score] };
}

export default PasswordStrengthChecker;
Output
// User types 'Hello1!'
// password state = 'Hello1!'
// score = 3 (length ✓, uppercase ✓, number ✓, special ✓ → actually score=4)
// Strength bar renders at 100% width in dark green
// Label reads: 'Strong'
Pro Tip: Derived State Doesn't Need useState
If a value can be calculated from existing state on every render — like 'strength' from 'password' above — don't put it in its own useState. Storing derived data in state creates a synchronisation problem: two sources of truth that can drift apart. Calculate it inline instead.
Production Insight
Derived state in useState causes a bug where the derived value and its source fall out of sync after a stale closure.
Every re-render recalculates derived state anyway — an extra useState just adds a risk of forgetting to update it.
Rule: if you can compute it from existing state, don't store it.
Key Takeaway
useState persists a value across renders by storing it outside the component.
Calling the setter schedules a re-render — it does not mutate the current variable.
Derived data = compute, don't store.
Should I Store This in useState?
IfValue needs to trigger a UI update when changed
UseUse useState (or useReducer for complex objects)
IfValue changes but no UI re-render needed
UseUse useRef instead
IfValue can be calculated from other state/props
UseCompute inline – no useState needed

useEffect — Running Code in Response to the World Outside React

React's job is to describe what the UI looks like for a given state. But real apps need to do things that aren't about describing UI — fetching data from an API, setting up a WebSocket, updating the document title, starting a timer. These are called side effects because they reach outside React's controlled rendering world.

useEffect is your officially sanctioned escape hatch for this. It runs after React has painted the screen, so it never blocks the browser from showing the UI. You hand it a function, and React runs that function after every render — unless you give it a dependency array, in which case it only re-runs when those specific values change.

The dependency array is the most misunderstood part. Think of it as a watch list. An empty array [] means 'run once after the first render, then never again' — equivalent to componentDidMount. No array at all means 'run after every single render' — which is almost never what you want. An array with values means 're-run whenever any of these values change.'

useEffect also supports a cleanup function — a function you return from inside the effect. React calls it before the next effect runs and when the component unmounts. This is where you clear timers, cancel fetch requests, and unsubscribe from anything you subscribed to.

GitHubUserProfile.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { useState, useEffect } from 'react';

// Real-world pattern: fetch data when a prop changes, cancel stale requests
function GitHubUserProfile({ username }) {
  const [profile, setProfile]   = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError]       = useState(null);

  useEffect(() => {
    // AbortController lets us cancel the fetch if 'username' changes
    // before the previous request finishes — this prevents a race condition
    const controller = new AbortController();

    async function fetchProfile() {
      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `https://api.github.com/users/${username}`,
          { signal: controller.signal } // Attach the abort signal to this fetch
        );

        if (!response.ok) {
          throw new Error(`GitHub API error: ${response.status}`);
        }

        const data = await response.json();
        setProfile(data);   // Update state — triggers a re-render with new data
      } catch (err) {
        // AbortError is expected when we cancel — don't treat it as a real error
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchProfile();

    // Cleanup function: runs before the next effect OR when component unmounts
    // This cancels the in-flight fetch if 'username' changes rapidly
    return () => {
      controller.abort();
    };

  }, [username]); // Dependency array: re-run this effect whenever 'username' changes

  if (isLoading) return <p>Loading {username}'s profile...</p>;
  if (error)     return <p>Error: {error}</p>;
  if (!profile)  return null;

  return (
    <div>
      <img src={profile.avatar_url} alt={`${profile.login}'s avatar`} width={80} />
      <h2>{profile.name ?? profile.login}</h2>
      <p>Public repos: {profile.public_repos}</p>
      <p>Followers: {profile.followers}</p>
    </div>
  );
}

export default GitHubUserProfile;
Output
// <GitHubUserProfile username="torvalds" />
// → Renders: 'Loading torvalds's profile...'
// → After fetch resolves (~300ms):
// [Avatar image]
// Linus Torvalds
// Public repos: 8
// Followers: 219842
//
// If parent re-renders with username="gaearon" before first fetch completes:
// → Previous fetch is aborted (no stale state update)
// → New fetch for 'gaearon' starts immediately
Watch Out: Missing the Cleanup = Memory Leaks
If your effect sets up a subscription, timer, or event listener and you don't return a cleanup function, that listener keeps running even after the component is gone. React 18's Strict Mode deliberately mounts components twice in development to surface exactly this bug — if you see your effect run twice, that's not a bug, it's the safety net working.
Production Insight
Missing cleanup in useEffect causes memory leaks that grow linearly with page navigations.
React 18 Strict Mode's double-mount reveals missing cleanups — don't suppress it.
Rule: every effect that creates a subscription must return a cleanup that destroys it.
Key Takeaway
useEffect runs after paint — never blocks the UI.
Dependency array = watch list: [] mount only, [value] re-run on change.
Cleanup is mandatory for subscriptions, timers, and fetches — it's not optional.
How Should I Configure useEffect?
IfSide effect should run once on mount (e.g., analytics page view)
UseuseEffect(() => { ... }, [])
IfSide effect depends on specific state/props and must re-run when those change
UseuseEffect(() => { ... }, [dep1, dep2])
IfSide effect runs on every render (almost never correct)
UseuseEffect(() => { ... }) — but reconsider; use refactor or custom hook

Combining useState and useEffect — The Full Reactive Loop

useState and useEffect are most powerful when they work together in a loop: state changes trigger a re-render, the re-render causes useEffect to re-run (if the state is in the dependency array), the effect produces a result that updates more state, and so on.

Understanding this loop is what separates developers who fight React from those who work with it. Let's build a real-world example — a live search component that debounces user input so it doesn't fire an API call on every keystroke.

The key insight here is that we have two separate pieces of state: the raw input value (which updates instantly for a responsive UI) and the debounced search term (which only updates after the user pauses typing). The effect watches the raw input and uses a timer to delay updating the search term. A second effect watches the search term and fires the actual API call.

This pattern — separating 'what the user is doing right now' from 'what we should actually act on' — is one of the most reusable patterns in React development.

DebouncedMovieSearch.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import { useState, useEffect } from 'react';

const OMDB_API_KEY = 'your_api_key_here'; // Get a free key at omdbapi.com
const DEBOUNCE_DELAY_MS = 400; // Wait 400ms after user stops typing

function DebouncedMovieSearch() {
  // 1. Raw input — updates on every keystroke for immediate UI feedback
  const [inputValue, setInputValue] = useState('');

  // 2. Debounced value — only updates after user pauses typing
  //    This is what we actually use to trigger the API call
  const [searchTerm, setSearchTerm] = useState('');

  // 3. Results from the API
  const [movies, setMovies] = useState([]);
  const [isSearching, setIsSearching] = useState(false);

  // EFFECT 1: Debounce — delay updating searchTerm until typing pauses
  useEffect(() => {
    // Set a timer to update searchTerm 400ms from now
    const debounceTimer = setTimeout(() => {
      setSearchTerm(inputValue.trim());
    }, DEBOUNCE_DELAY_MS);

    // Cleanup: if inputValue changes again before 400ms is up,
    // cancel the previous timer and start a fresh one
    // This is the core of debouncing
    return () => clearTimeout(debounceTimer);

  }, [inputValue]); // Re-run whenever the raw input changes

  // EFFECT 2: Fetch — fires only when searchTerm actually settles
  useEffect(() => {
    // Don't search for empty strings
    if (!searchTerm) {
      setMovies([]);
      return;
    }

    const controller = new AbortController();

    async function searchMovies() {
      setIsSearching(true);
      try {
        const url = `https://www.omdbapi.com/?s=${encodeURIComponent(searchTerm)}&apikey=${OMDB_API_KEY}`;
        const response = await fetch(url, { signal: controller.signal });
        const data = await response.json();

        // OMDB returns { Search: [...] } on success, { Error: '...' } on failure
        setMovies(data.Search ?? []);
      } catch (err) {
        if (err.name !== 'AbortError') console.error('Search failed:', err);
      } finally {
        setIsSearching(false);
      }
    }

    searchMovies();
    return () => controller.abort(); // Cancel if searchTerm changes mid-flight

  }, [searchTerm]); // Only re-run when the debounced term changes — NOT on every keystroke

  return (
    <div style={{ fontFamily: 'sans-serif', maxWidth: '480px', margin: '0 auto' }}>
      <input
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="Search for a movie..."
        style={{ width: '100%', padding: '10px', fontSize: '16px' }}
      />

      {isSearching && <p>Searching...</p>}

      <ul style={{ listStyle: 'none', padding: 0 }}>
        {movies.map((movie) => (
          <li key={movie.imdbID} style={{ padding: '8px 0', borderBottom: '1px solid #eee' }}>
            <strong>{movie.Title}</strong> ({movie.Year})
          </li>
        ))}
      </ul>

      {!isSearching && searchTerm && movies.length === 0 && (
        <p>No results found for "{searchTerm}"</p>
      )}
    </div>
  );
}

export default DebouncedMovieSearch;
Output
// User types 'inter' quickly (each letter fires setInputValue)
// → inputValue updates on each keystroke — input field stays responsive
// → Each keystroke cancels the previous debounce timer
// → 400ms after typing stops: searchTerm becomes 'inter'
// → EFFECT 2 fires: fetches from OMDB API
// → 'Searching...' appears briefly
// → Results render:
//
// Interstellar (2014)
// Interception (2009)
// Internal Affairs (1990)
// ... (up to 10 results)
Interview Gold: Two Effects, One Purpose
Splitting the debounce logic and the fetch logic into two separate effects is intentional. Each effect has a single responsibility and its own dependency array. This makes each one independently testable and much easier to reason about. Cramming both into one effect would create a tangled mess of timers and fetch calls.
Production Insight
Debouncing with a single effect mixing timer and fetch is brittle — a missing cleanup causes timer overlap.
Two effects with clear deps make the code testable and easy to reason about.
Rule: each useEffect should have exactly one responsibility.
Key Takeaway
Separate concerns into multiple effects — each with its own dependency array.
Debouncing: one effect for debounce, another for the resulting action.
Cleanup in each effect independently — don't rely on a single cleanup for multiple subscriptions.
How Many Effects Should I Use?
IfTwo unrelated side effects triggered by different dependencies
UseSplit into separate useEffect calls
IfTwo side effects that share the same dependency but are independent
UseStill separate effects — each with its own cleanup for clarity
IfA single side effect with multiple steps (e.g., subscribe then fetch)
UseOne effect, but ensure cleanup covers all subscriptions

Custom Hooks — Encapsulate Reusable Stateful Logic

A custom hook is a JavaScript function whose name starts with 'use' and that may call other hooks inside it. It's the mechanism React provides for sharing stateful logic between components — without adding components to the tree (no wrapper hell) and without duplicating code.

Let's extract the debounced search logic from the previous example into a reusable useDebounce hook. Then we can reuse it in any component that needs debounced input — a search box, an autocomplete, a filter field.

The rule: custom hooks are just functions. They do not add a new lifecycle. They do not have their own state that is somehow 'private'. They compose the primitives we already covered — useState, useEffect, useRef — into a reusable bundle.

A custom hook can return a single value, an array, or an object. It can accept arguments and use them inside its internal effects.

useDebounce.js + SearchComponent.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// useDebounce.js — a reusable custom hook
import { useState, useEffect } from 'react';

function useDebounce(value, delay = 400) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // Set a timer to update the debounced value after the delay
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Clear timer if value or delay changes before the timer fires
    return () => clearTimeout(handler);
  }, [value, delay]); // Re-run when value or delay changes

  return debouncedValue;
}

export default useDebounce;

// SearchComponent.jsx — using the custom hook
import { useState, useEffect } from 'react';
import useDebounce from './useDebounce';

function SearchComponent() {
  const [input, setInput] = useState('');
  const debouncedInput = useDebounce(input, 600); // longer delay for this component
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!debouncedInput) return;
    // Simulate fetching search results
    fetch(`/api/search?q=${debouncedInput}`)
      .then(res => res.json())
      .then(data => setResults(data));
  }, [debouncedInput]);

  return (
    <div>
      <input value={input} onChange={e => setInput(e.target.value)} />
      <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>
    </div>
  );
}
Output
// useDebounce('hello', 400) behaves like:
// initial: debouncedValue = 'hello'
// after 400ms without change: debouncedValue = 'hello' (unchanged, but wait resets)
// if user types 'helloworld' within 400ms, the timer clears and restarts
// after 400ms of no typing: debouncedValue = 'helloworld'
// The hook returns the most recent stable value.
Mental Model: Custom Hooks as Factories
  • Each call to a custom hook gets its own isolated state — no sharing unless you pass state up or use context.
  • The hook's internal useEffect runs in the component's lifecycle — it's not a separate lifecycle.
  • Custom hooks can call other custom hooks — composition is the key to building complex logic.
  • They reduce boilerplate and enforce consistent behaviour across your app.
Production Insight
Without custom hooks, debounce logic is copy-pasted across multiple components — different delays, different cleanups.
A single bug fix in the custom hook propagates to all consumers immediately.
Rule: extract reusable stateful logic into custom hooks — it's the React way to DRY.
Key Takeaway
Custom hooks encapsulate stateful logic — they are functions, not components.
Each call creates independent state — no sharing unless designed.
Extract when you see the same effect pattern in more than one place.
When to Create a Custom Hook
IfThe same useEffect + useState logic appears in two or more components
UseExtract into a custom hook
IfThe logic is complex but used in only one component
UseKeep inline — but consider extracting for testability
IfThe logic depends only on props and doesn't use hooks
UseUse a regular function instead (no 'use' prefix)

Rules of Hooks and Common Pitfalls in Production

React enforces two rules for hooks — they're not arbitrary. Violate them and your state will silently corrupt or skip updates.

Rule 1: Only call hooks at the top level of your component. Don't call them inside loops, conditionals, or nested functions. Reason: React relies on the order of hook calls to associate each hook with its state. If the order changes between renders, React misassigns state.

Rule 2: Only call hooks from React function components or custom hooks. You cannot call hooks from regular JavaScript functions or class components.

Beyond the rules, three common pitfalls cause production incidents: stale closures (using a value that has changed but the effect captured its old version), infinite loops (putting a new object/array literal in the dependency array), and missing cleanup (causing memory leaks). We covered each with examples earlier; here we bring them together with a single production scenario that combines all three.

StaleClosureAndCleanup.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Pitfall: stale closure + missing cleanup in a WebSocket subscription
import { useState, useEffect } from 'react';

function LiveChat({ channelId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    // BUG: 'channelId' not in deps array — the effect closes over the initial value
    const socket = new WebSocket(`wss://chat.example.com/${channelId}`);
    socket.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      setMessages(prev => [...prev, msg]); // Functional updater avoids stale state
    };

    // BUG: no cleanup! When channelId changes, the old socket stays open
    // Also when component unmounts, socket leaks
    // return () => socket.close();
  }, []); // Missing [channelId]

  return <ul>{messages.map(m => <li key={m.id}>{m.text}</li>)}</ul>;
}
Output
// User switches from channel 'general' to channel 'random'
// -> The Websocket still connects to 'general' (stale closure)
// -> Messages from 'general' still arrive but are added to the message list
// -> No cleanup, two sockets open simultaneously (memory leak)
// -> Fix: add channelId to deps, return cleanup to close socket and abort connection
Production Danger: Stale Closure + Missing Cleanup = Silent Bug
A missing dependency in useEffect combined with a missing cleanup creates a bug that is invisible during normal testing: the component appears to work, but under load or after navigation, subscriptions accumulate, memory grows, and data from wrong sources appears. Use the exhaustive-deps lint rule and always return cleanup.
Production Insight
A real incident: chat app showed messages from wrong channel because effect closed over initial channelId.
The fix: include channelId in deps and close old socket in cleanup.
Rule: every value read inside an effect must be in the dependency array, and every subscription must be cleaned up.
Key Takeaway
Only call hooks at top level — order matters.
Every value in an effect must be in the dependency array or justified with a ref.
Every subscription must have a cleanup — no exception.
Is Your Effect Safe in Production?
IfEffect reads a prop or state variable
UseInclude it in the dependency array — or use a ref to close over it intentionally
IfEffect sets up a subscription/event listener/timer
UseReturn a cleanup function that tears it down
IfEffect is called inside a conditional or loop
UseRefactor: move the hook to a child component or restructure logic — violating hook order rule
● Production incidentPOST-MORTEMseverity: high

Stale Closure in useEffect Wiped a User's Cart on Every Rerender

Symptom
Users reported that their shopping cart items would disappear when navigating to a different page and back. The cart itself was stored in a parent component's state and passed as a prop. A useEffect in the cart display component was logging the cart items each time they changed, but the log always showed the initial empty array even after the user added items.
Assumption
The developer assumed that because the effect only logs (a side effect with no external API call), missing the cart dependency wouldn't matter — the log would just run less often.
Root cause
The useEffect had an empty dependency array [], but the callback closed over the initial value of cart (empty array). Every time the component re-rendered with a new cart, the effect never re-ran because its dependencies hadn't changed. The log always showed the stale closure of cart from the first render.
Fix
Add cart to the dependency array: useEffect(() => { console.log('Cart updated:', cart); }, [cart]); Alternatively, if you need to run logic on every render but only want the side effect once, use useEffect(() => { /.../ }, []); but don't reference any external variables inside it that change.
Key lesson
  • If your effect reads a prop, state, or derived value, include it in the dependency array — even if you 'think' the effect doesn't need to re-run.
  • The eslint-plugin-react-hooks exhaustive-deps rule is your safety net; configure it in your ESLint config and treat warnings as errors.
  • A common pattern to avoid stale closures: use the functional updater form of setState (setCount(prev => prev+1)) when the new state depends on the old state.
Production debug guideSpot the patterns that cause stale data, infinite loops, and memory leaks in React hooks.3 entries
Symptom · 01
Effect runs but always sees old prop/state values; console.log shows initial values despite updates
Fix
Check dependency array — does it include every reactive value read inside the effect? Use eslint-plugin-react-hooks to auto-detect missing deps. Add missing variables.
Symptom · 02
Infinite loop: effect re-runs on every render without apparent change
Fix
Check for object/array literals as dependencies — {} or [] are new references each render. Use useMemo to stabilise or depend on primitives instead.
Symptom · 03
Memory leak warning in DevTools: 'Can't perform a React state update on an unmounted component'
Fix
Add an AbortController for fetch requests or a cleanup flag (e.g., let isCancelled = false) inside the effect. Return a cleanup function that aborts or sets the flag.
★ React Hooks Quick Debug Cheat SheetCommon signs of hooks misconfigurations and the fastest way to confirm the root cause.
useEffect runs twice in dev (React 18 Strict Mode)
Immediate action
Add a cleanup function inside the effect. The double-run is intentional to surface missing cleanup.
Commands
Add `return () => { /* cleanup */ }` in the effect
Check if the effect sets up a subscription, timer, or event listener without cleanup
Fix now
Wrap effect logic in a cleanup return; ensure no side-effects leak between unmount/remount
State update after component unmounted throws warning+
Immediate action
Use an AbortController for fetch or a cancelled flag in cleanup
Commands
Create `const abortController = new AbortController();` before async call
Return `() => abortController.abort();` from the effect
Fix now
Check if the effect's callback uses setState after unmount — cancel async operations
Effect re-runs infinitely despite no visible state change+
Immediate action
Check if an object/array is in dependency array without useMemo/useCallback
Commands
Replace `useEffect(fn, [obj])` with `useEffect(fn, [obj.id])` (primitive)
Wrap the object in `useMemo` or the function in `useCallback`
Fix now
Avoid using non-primitive values directly in dependency arrays; derive a stable key
AspectuseStateuseEffect
Primary purposeStore and update data that triggers re-rendersRun side effects in response to renders or state changes
When it runsSetter schedules the next render synchronouslyRuns asynchronously after the browser has painted
Return valueReturns [currentValue, setter]Returns an optional cleanup function
Equivalent class conceptthis.state / this.setStatecomponentDidMount + componentDidUpdate + componentWillUnmount
Blocks rendering?No — setter just schedules a future renderNo — runs after paint, never blocks the UI
Dependency array?Not applicableControls when the effect re-runs (critical for performance)
Common misuseStoring derived data that could be calculatedMissing cleanup, causing memory leaks and stale updates
Needs cleanup?NoYes — whenever you open a subscription, timer, or listener

Key takeaways

1
useState stores values outside your component function so they survive re-renders
calling the setter doesn't mutate the current variable, it schedules the next render with a new value.
2
useEffect runs after the browser paints, not during render
never use it to calculate values for the current render; do that inline or with useMemo instead.
3
The dependency array is a 'watch list', not an optimisation hint
if a value your effect reads isn't in the array, your effect will silently see a stale version of it forever.
4
Always return a cleanup function from any useEffect that sets up a timer, listener, or fetch request
React 18 Strict Mode will deliberately run your effect twice in development to prove you did this correctly.
5
Custom hooks are the React way to share stateful logic
they are functions, not components, and each call gets independent state.

Common mistakes to avoid

4 patterns
×

Reading state immediately after calling the setter

Symptom
After calling setCount(count + 1), reading 'count' on the very next line still gives the old value. The setter doesn't mutate the variable; it schedules a re-render. The new value only exists in the NEXT render cycle.
Fix
If you need to compute something based on the new value right away, calculate it before calling the setter and store it in a local variable: const newCount = count + 1; setCount(newCount); doSomethingWith(newCount);
×

Forgetting to include values in the useEffect dependency array

Symptom
If your effect uses a prop or state value but you omit it from the deps array, your effect silently closes over the stale initial value forever. Symptom: your effect runs but always sees the same old data no matter how many times state updates.
Fix
Include every reactive value your effect reads in the dependency array. Use the eslint-plugin-react-hooks 'exhaustive-deps' rule — it will catch this automatically at write time.
×

Putting an object or array literal directly in the dependency array

Symptom
useEffect compares deps with Object.is (strict equality). A new object literal like {} or [] is a brand new reference on every render, so your effect re-runs on every render even though nothing logically changed. Symptom: infinite loops or excessive API calls.
Fix
Either move the object inside the effect where it won't be compared, use useMemo to stabilise it between renders, or restructure the state so you're depending on a primitive value (string, number, boolean) instead.
×

Using useEffect for derived state computations

Symptom
Setting state inside useEffect based on other state often leads to unnecessary re-renders and stale data. The effect runs after paint, so the derived value appears one frame late.
Fix
Compute derived values directly during render — no useEffect needed. If computation is expensive, wrap it in useMemo.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between passing no dependency array and passing an...
Q02SENIOR
If useState updates are asynchronous, how would you safely update state ...
Q03SENIOR
A useEffect fetches data, but in development with React 18 Strict Mode y...
Q04SENIOR
How would you build a custom hook that tracks the previous value of a pr...
Q01 of 04SENIOR

What's the difference between passing no dependency array and passing an empty array [] to useEffect, and why does it matter?

ANSWER
No dependency array means the effect runs after every single render — almost always a bug because it leads to infinite loops or excessive side effects. An empty array [] means the effect runs only once after the first render and never again, equivalent to componentDidMount. The difference matters because a missing array can cause performance problems and unintended side effects, while an empty array can cause stale closures if the effect reads any reactive values that change over time. Always supply the correct array and include all values the effect reads.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Can I call useState and useEffect inside a conditional or loop?
02
Why does my useEffect run twice on mount in React 18?
03
Should I use one useState call with an object for related state, or multiple useState calls?
04
How do I test a custom hook?
🔥

That's React.js. Mark it forged?

5 min read · try the examples if you haven't

Previous
React State Management
4 / 47 · React.js
Next
React useContext and useReducer