Shopping cart cleared on page navigation due to useEffect stale closure over empty array.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
Stale closures in React are a class of bugs where a callback or event handler captures an outdated value of a variable, typically from a previous render. They arise because React Hooks like useState and useEffect rely on JavaScript closures over the component's render scope.
Each render creates a fresh closure, and if a useEffect callback or a useState setter function references a variable from an earlier render (e.g., inside a setTimeout, setInterval, or an async operation), it will see the stale value, not the current one. This is the root cause of the 'Cart Deleted on Navigation' bug: a cleanup or navigation handler uses a stale cart state, triggering an unintended deletion.
useState gives you a state variable and a setter that persists across re-renders via React's internal fiber tree. useEffect runs side effects after render, and its dependency array controls when it re-executes. The trap is that useEffect's callback closes over the state value from the render when the effect was created.
If you omit a dependency or use an empty array [], the effect runs once and captures the initial state—any later state changes are invisible to it. This is why you see bugs like a cart being cleared on navigation: the effect's cleanup function holds a stale reference to the cart state, and when navigation triggers cleanup, it uses that old value to mutate or reset the cart.
The fix is to either include all reactive values in the dependency array, use functional updates in useState (setCart(prev => ...)) to avoid closure issues, or use useRef to hold mutable values that don't trigger re-renders. In production, this pattern is especially dangerous with async flows, debounced inputs, or event listeners added in useEffect.
Tools like ESLint's react-hooks/exhaustive-deps rule catch many cases, but understanding the closure mechanics is essential—otherwise you'll chase phantom bugs where state seems to 'reset' or 'lag' behind the UI.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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 gives you a state variable and a setter; useEffect lets you run side effects after render. The core mechanic: each render captures its own closure over state. When you pass a callback to useEffect that references state, that callback closes over the state value from that specific render — not the latest one. This is the root cause of stale closures. In practice, useEffect runs after every render by default, but its dependency array controls when it re-runs. If you omit a dependency, the effect still sees the old state. If you include it, the effect re-creates the closure with fresh state. The real trap: functions inside useEffect (like event handlers or timers) also close over the render's state. Use useRef or functional updates to break the closure. This matters because stale closures cause silent data loss — like deleting the wrong cart item because the effect captured an outdated index.
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.
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;
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.
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;
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.
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;
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 — 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> ); }
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.
// 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>; }
React enforces two immutable rules for hooks. Violate these and your state will silently corrupt: hooks may return the wrong value, effects may run with incorrect dependencies, and the component may re-render with stale data. This cheat sheet gives you the rules at a glance plus the most common violations that crash production apps.
Rule 1: Only call hooks at the top level of your component. - Never call hooks inside conditions, loops, or after an early return. - Why: React tracks hooks by their call order on every render. If the order changes — say, because a condition skips a hook call — the state for subsequent hooks gets misassigned. A useState for email could accidentally receive the value meant for password.
Rule 2: Only call hooks from React function components or custom hooks. - Never call hooks from regular JavaScript functions (helper functions, event handlers outside the component, or class components). - Why: The hook mechanism relies on the React fiber context that exists only during the render of a function component or the execution of a custom hook that itself is called from a component.
Common violations that slip into production: - Calling useState inside a useEffect callback. - Calling useEffect inside a for loop. - Returning early from a component before a hook call, causing later hooks to shift positions. - Calling a custom hook from a helper function that is not a component.
How to prevent rules violations: - Install and enable eslint-plugin-react-hooks. It auto-detects hook order violations, missing dependencies, and hook calls outside valid scope. - Configure your ESLint rules to treat hook rule violations as errors, not warnings. - Use React DevTools to inspect the hooks tree — if a component shows fewer or misordered hooks than expected, you have a rules violation.
// ❌ VIOLATION: conditional hook call function SearchForm({ showFilters }) { const [query, setQuery] = useState(''); if (showFilters) { // This hook runs only when showFilters is true — order changes! const [filter, setFilter] = useState(''); } // ... } // ✅ FIX: always call hooks unconditionally at top level function SearchForm({ showFilters }) { const [query, setQuery] = useState(''); const [filter, setFilter] = useState(''); // Use filter only when showFilters is true // ... }
eslint-plugin-react-hooks plugin is not optional. It catches conditional hook calls, missing dependencies, and invalid hook locations before they hit production. Configure rules-of-hooks as error and exhaustive-deps as warn at minimum.Every time your component renders, React checks whether any dependency of a useEffect (or useCallback/useMemo) has changed since the last render. It does this using Object.is comparison — a value-comparison algorithm nearly identical to the strict equality operator ===, but with two important differences: NaN is considered equal to NaN (unlike NaN !== NaN), and -0 is considered different from +0. For all other values, Object.is works like ===.
This comparison is critical because it is reference-based for objects, arrays, and functions. Two object literals {} are never Object.is-equal because they occupy different memory addresses. If you put an object literal directly in a dependency array, the effect will re-run on every render, even if the object's contents are identical.
Decision Matrix for Dependency Changes:
| Dependency type | Example | Object.is(new, old)? | Effect re-runs? |
|---|---|---|---|
| primitive (string, number, boolean, null, undefined) | "hello" vs "hello" | true | No |
| primitive changed | "hello" vs "world" | false | Yes |
| NaN | NaN vs NaN | true (special) | No |
| -0 vs +0 | -0 vs +0 | false (special) | Yes |
| same object reference | const obj = {}; pass same ref twice | true | No |
| object literal (new reference) | {} vs {} | false | Yes ⚠️ |
| array literal (new reference) | [] vs [] | false | Yes ⚠️ |
| inline arrow function | () => {} vs () => {} | false | Yes ⚠️ |
When to use this decision matrix in practice: - If your effect depends on a prop that is an object, you'll get unexpected re-renders unless the parent memoizes that object with useMemo. - If your effect depends on an inline callback, wrap it in useCallback to stabilise the reference. - If your effect depends on a derived value, compute it with useMemo to return a stable reference.
Always prefer primitive dependencies (strings, numbers, booleans) when possible. For example, instead of useEffect(() => { ... }, [user]) use useEffect(() => { ... }, [user.id]). The former re-runs on every new user object reference even if the same user is passed; the latter only re-runs when the user ID actually changes.
import { useState, useEffect, useMemo } from 'react'; function Profile({ user }) { // ❌ BAD: depends on the user object reference // This effect will re-run every time the parent re-renders // even if the user data hasn't changed useEffect(() => { console.log('Fetching permissions for', user.id); }, [user]); // ✅ GOOD: depend on a primitive — the user's id useEffect(() => { console.log('Fetching permissions for', user.id); }, [user.id]); // If you must depend on the object but want to avoid re-runs // when the reference changes but content is the same, memoize: const stableUser = useMemo(() => user, [user.id]); useEffect(() => { console.log('Stable effect for', stableUser.name); }, [stableUser]); return <div>{user.name}</div>; }
{} or array literal [] directly in the dependency array of useEffect is a sure path to an infinite loop. Each render creates a new reference, so the effect runs, potentially updates state, triggering another render, and so on. Always memoize objects/arrays with useMemo or depend on primitive values.{ channel } object literal. The fix: depend on channel.id (a primitive) instead. This reduced API calls by 95% and eliminated the infinite re-render loop that was causing the browser to freeze after a few seconds of use.React batches state updates — when you call setState multiple times in the same synchronous event handler, React groups them and performs a single re-render. However, each setState(newValue) uses the state value from the current render, not the pending updates. This means if you call setCount(count + 1) three times in a row, each call sees the same count value, and the result is still a single increment — only the last call's value wins. This is a silent bug that developers encounter when implementing counters, toggles, or any state that depends on its previous value.
The solution is the functional updater form: setState(prevState => newState). React passes the most recent prevState (after previous batched updates) into the callback, ensuring each update builds on the last. This is sometimes called an 'atomic update' because the function is guaranteed to execute atomically with respect to the batch.
When must you use functional setState? 1. When the new state depends on the previous state. Example: incrementing a counter, toggling a boolean, adding to an array. 2. When calling the setter from a closure where the state variable may be stale. Example: inside a useEffect cleanup function, a setTimeout callback, or an event handler that captures the state at the time of registration. 3. When multiple consecutive setState calls need to accumulate. Example: rapid button clicks for quantity updates.
When can you skip functional setState? - When the new state does not depend on the previous state (e.g., setName('Alice')). - When you are setting a value that you just computed and don't need to chain updates.
Functional setState also works with useReducer dispatches, which are already functional by design.
import { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); function handleClick() { // ❌ BAD: all three see the same stale count (0) setCount(count + 1); // count = 0, sets to 1 (but overwritten) setCount(count + 1); // count = 0, sets to 1 (overwrites previous) setCount(count + 1); // count = 0, sets to 1 (final result) // Result: count becomes 1, not 3! } function handleAtomicClick() { // ✅ GOOD: each call gets the latest previous count setCount(prev => prev + 1); // prev=0, sets to 1 setCount(prev => prev + 1); // prev=1, sets to 2 setCount(prev => prev + 1); // prev=2, sets to 3 // Result: count becomes 3 } return ( <div> <p>Count: {count}</p> <button onClick={handleClick}>Add 1 (broken)</button> <button onClick={handleAtomicClick}>Add 1 (atomic)</button> </div> ); } // Real-world pattern: async queue with functional setState function useTaskQueue() { const [queue, setQueue] = useState([]); function addTask(task) { // Functional updater ensures we don't lose tasks if called rapidly setQueue(prev => [...prev, task]); } function processNext() { setQueue(prev => prev.slice(1)); // Remove first task atomically } return { queue, addTask, processNext }; }
setState(prev => ...prev) avoids that trap.setSeats(seats => ...seats) correctly, but one part of the code used setSeats([...seats, newSeat]) without functional updater inside a useEffect that debounced selection. Switching to setSeats(prev => [...prev, newSeat]) fixed the race condition where selections were dropped under high-frequency clicks.You've seen it. A useEffect with setInterval that reads state, but every tick logs the initial value. That's a stale closure. The callback passed to setInterval captured the state from the render where the effect ran. React never re-runs the effect because the dependency array says nothing changed. So your timer is stuck in 2021.
The fix isn't always adding the state to deps. That kills the interval and restarts it every render, defeating the purpose. Instead, use the functional form of setState when you need the latest state without restarting the interval. Or use a ref to hold the current value. The useRef never goes stale because it's the same object across renders. Read .current inside the callback and you get the live value. This pattern is essential for polling, animations, and any long-lived listener that reads state.
// io.thecodeforge — javascript tutorial import { useState, useEffect, useRef } from 'react'; export default function PollingCounter() { const [count, setCount] = useState(0); const countRef = useRef(count); // Keep ref in sync with state useEffect(() => { countRef.current = count; }, [count]); useEffect(() => { const id = setInterval(() => { // countRef.current is NEVER stale console.log('Tick:', countRef.current); setCount(c => c + 1); // functional update avoids stale state too }, 1000); return () => clearInterval(id); }, []); // Empty deps — interval starts once return <div>Count: {count}</div>; }
count to the dependency array will restart the interval on every increment — that kills performance and creates race conditions in polling. Use refs or functional updates instead.setInterval or addEventListener callback without using a ref or functional update — the callback captures stale values.Your component unmounts. Your effect's async operation (fetch, subscription, timeout) resolves a second later. Inside the callback, you call setState on an unmounted component. React logs a warning in dev, but in production? Depends on the build. React 18+ suppresses the warning, leaving you with a memory leak or worse: a crash when the callback tries to update state that's been garbage collected.
The pattern: every useEffect that creates a side effect must return a cleanup function. Not optional. For fetch, use an AbortController and call in cleanup. For subscriptions, call controller.abort().unsubscribe(). For timeouts, clearTimeout. This isn't boilerplate — it's the difference between an app that survives a route change and one that silently corrupts its state tree.
If your effect does something that can't be aborted (e.g., analytics beacon), guard the state update with a ref that tracks mount status. But that's a crutch. Prefer abortable APIs.
// io.thecodeforge — javascript tutorial import { useState, useEffect } from 'react'; export default function UserProfile({ userId }) { 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') { console.error('Fetch failed:', err); } }); // Cleanup aborts the request if component unmounts or userId changes return () => controller.abort(); }, [userId]); return <div>{user?.name ?? 'Loading...'}</div>; }
useRef(true) for mounted state, set it to false on cleanup, and check it before calling setState. But this is a band-aid — refactor to abortable APIs.useEffect that has an async operation MUST return a cleanup function that cancels the work. No exceptions.You write useEffect(() => { fetchUser(userId); }, []). Linter screams. You silence it with a comment. Two weeks later, the profile page doesn't update when you switch users. Welcome to the bug that wasted a sprint.
React's dependency array is not a performance hint — it's a contract. You're telling React: 'This effect should re-run only when these values change.' If you omit a value that the effect reads, you've created a stale closure. The effect runs with the old userId, setState, or whatever you left out. The fix isn't 'but it works in dev' — it'll break in production when users navigate fast or the component re-renders for another reason.
The exhaustive-deps ESLint rule exists because every single one of us has shipped this bug. Turn it on. When the linter suggests adding a dep, do it. If you truly cannot add it (e.g., a stable function from a custom hook), use useCallback to stabilize the reference. But never, ever add an inline comment to silence the rule without a code review.
// io.thecodeforge — javascript tutorial import { useState, useEffect } from 'react'; export default function TeamDashboard({ teamId }) { const [members, setMembers] = useState([]); // BUG: Missing teamId in deps — effect only runs on mount useEffect(() => { fetch(`/api/teams/${teamId}/members`) .then(res => res.json()) .then(setMembers); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return <div>{members.length} members</div>; } // FIX: // useEffect(() => { // fetch(`/api/teams/${teamId}/members`) // .then(res => res.json()) // .then(setMembers); // }, [teamId]);
useEffect with a missing dependency is a guaranteed production bug. Respect exhaustive-deps or document why you're overriding it with a survival plan.Every senior dev has debugged a race condition that looked like a ghost in the machine. When your effect kicks off an async request — API call, database read, file load — the component might unmount or re-render before that promise resolves. The result? You try to setState on an unmounted component, or worse, you display stale data from a slow response that arrived after a newer one.
React 18 surfaces the memory leak warning. But the real danger is silent: your UI shows yesterday's data because a slow promise resolved after a fast one. The fix isn't just a cleanup flag. It's understanding that every async effect is a race between the effect's lifecycle and the network.
Use an ignore flag paired with the cleanup function. Set it true on cleanup, false when the effect runs. Guard every state update with if (!ignore). That single pattern has saved my production deploys more times than I can count.
// io.thecodeforge — javascript tutorial import { useState, useEffect } from 'react'; export default function UserProfile({ userId }) { const [user, setUser] = useState(null); const [error, setError] = useState(null); useEffect(() => { let ignore = false; fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => { if (!ignore) setUser(data); }) .catch(err => { if (!ignore) setError(err); }); return () => { ignore = true; }; }, [userId]); return <div>{user ? user.name : 'Loading...'}</div>; }
Here's a bug that makes juniors question their sanity and seniors reach for the profiler. You pass an object or array as a prop, put it in the dependency array, and suddenly your effect runs every render. The component melts. The cause: JavaScript compares objects by reference, not by value. {} !== {} even if the contents are identical.
Every time your parent re-renders and recreates that object literal, you get a new reference. React sees a changed dependency and fires the effect. The solution isn't JSON.stringify in the dep array — that's a code smell. The real fix: store primitive values in deps. If you need a config object, memoize it with useMemo or deconstruct it into primitives.
I've seen teams add eslint-disable to dependency arrays out of frustration. Don't. The eslint rule is your friend. Learn to work with it. Destructure what you actually use. Your bundle and your users will thank you.
// io.thecodeforge — javascript tutorial import { useEffect, useState } from 'react'; function SearchForm({ initialFilters }) { const [results, setResults] = useState([]); // BAD: object literal creates new reference every render useEffect(() => { fetchSearch(initialFilters).then(setResults); }, [initialFilters]); // ⚠️ This runs every render // FIX: destructure to primitives const { term, category } = initialFilters; useEffect(() => { fetchSearch({ term, category }).then(setResults); }, [term, category]); // ✅ Only runs when values change return <div>{results.length} results</div>; }
Stale closures, missing dependencies, and infinite loops are the three most common useState/useEffect bugs in production. Functional setState eliminates stale state reads by using the previous value directly. Empty dependency arrays run effects once but can hide bugs when props change. Effects with async logic always need a cleanup function to abort stale requests — a flag like let cancelled = false prevents race conditions. Object references in dependency arrays cause unnecessary re-runs because React compares by reference; use primitive values or useMemo to stabilize references. Every effect should either return a cleanup or have a deliberate reason not to.
// io.thecodeforge — javascript tutorial import { useState, useEffect } from 'react'; export default function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { let cancelled = false; fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => { if (!cancelled) setUser(data); }); return () => { cancelled = true; }; }, [userId]); return <div>{user?.name}</div>; }
useContext eliminates prop drilling by letting components read shared state directly from a React context. Create context with React.createContext(), wrap a provider around your tree, then call useContext(MyContext) in any child. When the provider's value changes, every consuming component re-renders automatically. Use it sparingly — putting everything in context kills performance because all consumers re-render even if they only use a small piece of the value. Split contexts by domain (e.g., ThemeContext, AuthContext) and memoize the provider value with useMemo to avoid unnecessary re-renders. useContext is not a state management replacement; it's a dependency injection tool for React's tree.
// io.thecodeforge — javascript tutorial import { createContext, useContext, useState, useMemo } from 'react'; const ThemeContext = createContext('light'); export default function App() { const [theme, setTheme] = useState('dark'); const value = useMemo(() => ({ theme, setTheme }), [theme]); return ( <ThemeContext.Provider value={value}> <Toolbar /> </ThemeContext.Provider> ); } function Toolbar() { const { theme } = useContext(ThemeContext); return <div style={{ background: theme === 'dark' ? '#333' : '#fff' }} />; }
cart dependency wouldn't matter — the log would just run less often.[], 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.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.eslint-plugin-react-hooks to auto-detect missing deps. Add missing variables.{} or [] are new references each render. Use useMemo to stabilise or depend on primitives instead.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.Add `return () => { /* cleanup */ }` in the effectCheck if the effect sets up a subscription, timer, or event listener without cleanupCreate `const abortController = new AbortController();` before async callReturn `() => abortController.abort();` from the effectReplace `useEffect(fn, [obj])` with `useEffect(fn, [obj.id])` (primitive)Wrap the object in `useMemo` or the function in `useCallback`| Aspect | useState | useEffect |
|---|---|---|
| Primary purpose | Store and update data that triggers re-renders | Run side effects in response to renders or state changes |
| When it runs | Setter schedules the next render synchronously | Runs asynchronously after the browser has painted |
| Return value | Returns [currentValue, setter] | Returns an optional cleanup function |
| Equivalent class concept | this.state / this.setState | componentDidMount + componentDidUpdate + componentWillUnmount |
| Blocks rendering? | No — setter just schedules a future render | No — runs after paint, never blocks the UI |
| Dependency array? | Not applicable | Controls when the effect re-runs (critical for performance) |
| Common misuse | Storing derived data that could be calculated | Missing cleanup, causing memory leaks and stale updates |
| Needs cleanup? | No | Yes — whenever you open a subscription, timer, or listener |
| File | Command / Code | Purpose |
|---|---|---|
| PasswordStrengthChecker.jsx | function PasswordStrengthChecker() { | useState |
| GitHubUserProfile.jsx | function GitHubUserProfile({ username }) { | useEffect |
| DebouncedMovieSearch.jsx | const OMDB_API_KEY = 'your_api_key_here'; // Get a free key at omdbapi.com | Combining useState and useEffect |
| useDebounce.js + SearchComponent.jsx | function useDebounce(value, delay = 400) { | Custom Hooks |
| StaleClosureAndCleanup.jsx | function LiveChat({ channelId }) { | Rules of Hooks and Common Pitfalls in Production |
| HookRuleViolation.jsx | function SearchForm({ showFilters }) { | Rules of Hooks |
| ObjectIsComparison.jsx | function Profile({ user }) { | Dependency Comparison |
| FunctionalSetStateExample.jsx | function Counter() { | When to Use Functional setState (Atomic Updates Guide) |
| StaleInterval.js | export default function PollingCounter() { | Stale Closures in useEffect |
| CleanupFetch.js | export default function UserProfile({ userId }) { | useEffect Cleanup |
| MissingDepsBug.js | export default function TeamDashboard({ teamId }) { | Dependency Arrays Aren't Optional |
| AsyncEffectRace.js | export default function UserProfile({ userId }) { | The Pitfall of Async Operations in useEffect |
| ObjectDepBug.js | function SearchForm({ initialFilters }) { | The Hidden Cost of Object References in Dependency Arrays |
| RaceConditionFix.js | export default function UserProfile({ userId }) { | Key Takeaways |
| ThemeContext.js | const ThemeContext = createContext('light'); | useContext() |
What's the difference between passing no dependency array and passing an empty array [] to useEffect, and why does it matter?
If useState updates are asynchronous, how would you safely update state that depends on its previous value — for example, incrementing a counter from multiple rapid clicks?
A useEffect fetches data, but in development with React 18 Strict Mode you notice the API is being called twice on mount. Is this a bug? Why does it happen, and how should you handle it?
How would you build a custom hook that tracks the previous value of a prop or state?
No — and this is one of the Rules of Hooks. React tracks hooks by their call order on every render. If you put a hook inside an if statement or loop, that order can change between renders and React loses track of which state belongs to which hook. Always call hooks at the top level of your component, unconditionally.
React 18's Strict Mode deliberately mounts, unmounts, and remounts every component in development to surface missing cleanup functions. It does NOT do this in production. If your effect has visible side effects from running twice, it means you're missing a cleanup function — which is exactly the bug Strict Mode is designed to reveal.
Prefer multiple useState calls for state values that change independently. Group them into a single object only when they always change together — like form fields that are submitted as a unit. With a single object, updating one field requires spreading the old state to avoid overwriting the others, which adds boilerplate. For complex state with many interdependent fields, consider useReducer instead.
Use @testing-library/react-hooks (or the renderHook utility from React Testing Library in newer versions). This allows you to render a hook in isolation, call its returned functions, and assert on state updates. You can also test hooks indirectly by testing a component that uses the hook.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
12 min read · try the examples if you haven't