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.
✦ Definition~90s read
What is React Hooks?
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.
★
Imagine your React component is a whiteboard in a classroom.
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.
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.
Why React Hooks useState and useEffect Are the Root of Stale Closure Bugs
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.
Stale Closure Trap
A useEffect with an empty dependency array captures the initial state forever — any async callback inside it will never see updated state.
Production Insight
E-commerce cart: useEffect fetches item details on mount, but the item ID comes from state. User navigates to a new item — effect doesn't re-run because ID dependency is missing. Cart shows stale product info. Rule: every variable used inside useEffect must be in the dependency array, or use a ref.
Key Takeaway
Each render has its own closure over state — useEffect captures that render's state, not the latest.
Missing dependencies in useEffect cause silent bugs that only surface under specific timing (async, timers, events).
Use functional updates (setState(prev => ...)) or useRef to read the latest state without re-running the effect.
thecodeforge.io
React useEffect Stale Closure Flow
React Hooks Usestate Useeffect
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 indicatorfunctionPasswordStrengthChecker() {
// useState returns [currentValue, setterFunction]// React stores 'password' outside this function so it survives re-rendersconst [password, setPassword] = useState('');
// Derived state — calculated fresh each render from 'password'// No need for a separate useState here; it's not independently settableconst strength = calculateStrength(password);
functionhandleChange(event) {
// We pass the new value to the setter — React schedules a re-render// After re-render, 'password' will equal event.target.valuesetPassword(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>
);
}
functioncalculateStrength(password) {
let score = 0;
if (password.length >= 8) score++; // Minimum lengthif (/[A-Z]/.test(password)) score++; // Has uppercaseif (/[0-9]/.test(password)) score++; // Has numberif (/[^A-Za-z0-9]/.test(password)) score++; // Has special charconst 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] };
}
exportdefaultPasswordStrengthChecker;
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 requestsfunctionGitHubUserProfile({ 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 conditionconst controller = newAbortController();
asyncfunctionfetchProfile() {
setIsLoading(true);
setError(null);
try {
const response = awaitfetch(
`https://api.github.com/users/${username}`,
{ signal: controller.signal } // Attach the abort signal to this fetch
);
if (!response.ok) {
thrownewError(`GitHubAPI 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 errorif (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 rapidlyreturn () => {
controller.abort();
};
}, [username]); // Dependency array: re-run this effect whenever 'username' changesif (isLoading) return <p>Loading {username}'s profile...</p>;
if (error) return <p>Error: {error}</p>;
if (!profile) returnnull;
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>
);
}
exportdefaultGitHubUserProfile;
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.
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 typingfunctionDebouncedMovieSearch() {
// 1. Raw input — updates on every keystroke for immediate UI feedbackconst [inputValue, setInputValue] = useState('');
// 2. Debounced value — only updates after user pauses typing// This is what we actually use to trigger the API callconst [searchTerm, setSearchTerm] = useState('');
// 3. Results from the APIconst [movies, setMovies] = useState([]);
const [isSearching, setIsSearching] = useState(false);
// EFFECT 1: Debounce — delay updating searchTerm until typing pausesuseEffect(() => {
// Set a timer to update searchTerm 400ms from nowconst 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 debouncingreturn () => clearTimeout(debounceTimer);
}, [inputValue]); // Re-run whenever the raw input changes// EFFECT 2: Fetch — fires only when searchTerm actually settlesuseEffect(() => {
// Don't search for empty stringsif (!searchTerm) {
setMovies([]);
return;
}
const controller = newAbortController();
asyncfunctionsearchMovies() {
setIsSearching(true);
try {
const url = `https://www.omdbapi.com/?s=${encodeURIComponent(searchTerm)}&apikey=${OMDB_API_KEY}`;const response = awaitfetch(url, { signal: controller.signal });
const data = await response.json();
// OMDB returns { Search: [...] } on success, { Error: '...' } on failuresetMovies(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 keystrokereturn (
<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>
);
}
exportdefaultDebouncedMovieSearch;
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
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 hookimport { useState, useEffect } from'react';
functionuseDebounce(value, delay = 400) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// Set a timer to update the debounced value after the delayconst handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Clear timer if value or delay changes before the timer firesreturn () => clearTimeout(handler);
}, [value, delay]); // Re-run when value or delay changesreturn debouncedValue;
}
exportdefault useDebounce;
// SearchComponent.jsx — using the custom hookimport { useState, useEffect } from'react';
import useDebounce from'./useDebounce';
functionSearchComponent() {
const [input, setInput] = useState('');
const debouncedInput = useDebounce(input, 600); // longer delay for this componentconst [results, setResults] = useState([]);
useEffect(() => {
if (!debouncedInput) return;
// Simulate fetching search resultsfetch(`/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 subscriptionimport { useState, useEffect } from'react';
functionLiveChat({ 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
Rules of Hooks — Scannable Cheat Sheet
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.
HookRuleViolation.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ VIOLATION: conditional hook callfunctionSearchForm({ 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 levelfunctionSearchForm({ showFilters }) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('');
// Use filter only when showFilters is true// ...
}
Output
// If showFilters changes from false to true, the hook order shifts:
// first render: hooks are [useState(query), useState(filter)? actually no filter]
// second render: hooks are [useState(query), useState(filter)]
// React assigns the state of the first render's second position to filter,
// which is actually the state meant for something else — causing corruption.
ESLint is Your Safety Net
The 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.
Production Insight
A major SaaS dashboard once corrupted user filters because a developer added a new feature that conditionally called a custom hook only on the admin page. When non-admin users rendered the same component, the hook order shifted, causing the user ID state to be read as the filter state — resulting in data from wrong accounts being shown. The fix was to always call hooks unconditionally and pass a flag to conditionally apply logic inside the hook.
Key Takeaway
Hooks must be called in the same order on every render. Never nest hooks inside conditionals, loops, or early returns. Use the ESLint plugin to enforce this automatically.
Dependency Comparison: How React Decides to Re-run Effects (Object.is Decision Matrix)
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.
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.
ObjectIsComparison.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
import { useState, useEffect, useMemo } from'react';
functionProfile({ 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 changeduseEffect(() => {
console.log('Fetching permissions for', user.id);
}, [user]);
// ✅ GOOD: depend on a primitive — the user's iduseEffect(() => {
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>;
}
Output
// Parent renders <Profile user={someUser} />
// If parent re-renders but someUser reference changes (even with same data):
// - The first effect (dep on user) WILL re-run -> unnecessary API call
// - The second effect (dep on user.id) will NOT re-run if id unchanged -> efficient
// - The third effect (dep on stableUser) will NOT re-run if user.id unchanged -> efficient
Infinite Loop Danger: Object Literals in Deps
Putting an object literal {} 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.
Production Insight
A live-streaming app's chat component re-fetched messages on every render because the effect depended on a { 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.
Key Takeaway
React compares dependencies using Object.is (reference equality). Never put object/array/function literals directly in dependency arrays; memoize them or depend on primitive values.
Object.is Dependency Comparison Flow
When to Use Functional setState (Atomic Updates Guide)
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.
FunctionalSetStateExample.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
import { useState } from'react';
functionCounter() {
const [count, setCount] = useState(0);
functionhandleClick() {
// ❌ 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!
}
functionhandleAtomicClick() {
// ✅ GOOD: each call gets the latest previous countsetCount(prev => prev + 1); // prev=0, sets to 1setCount(prev => prev + 1); // prev=1, sets to 2setCount(prev => prev + 1); // prev=2, sets to 3// Result: count becomes 3
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Add1 (broken)</button>
<button onClick={handleAtomicClick}>Add1 (atomic)</button>
</div>
);
}
// Real-world pattern: async queue with functional setStatefunctionuseTaskQueue() {
const [queue, setQueue] = useState([]);
functionaddTask(task) {
// Functional updater ensures we don't lose tasks if called rapidlysetQueue(prev => [...prev, task]);
}
functionprocessNext() {
setQueue(prev => prev.slice(1)); // Remove first task atomically
}
return { queue, addTask, processNext };
}
Output
// After clicking the first button once: count = 1 (broken)
// After clicking the second button once: count = 3 (atomic)
// The functional updater guarantees that each call uses the latest pending state.
Always Use Functional setState in useEffect Cleanup
If you need to update state inside a useEffect cleanup (e.g., to reset a status), always use the functional updater form. The cleanup runs after the component unmounts or before the next effect run — the closure may capture stale state values from the previous render. Using setState(prev => ...prev) avoids that trap.
Production Insight
A ticket booking system lost seat selections when users clicked multiple seating options rapidly. The reducer was using 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.
Key Takeaway
Use the functional updater form of setState whenever the new state depends on the previous state or when calling the setter from a closure. It guarantees atomic updates even inside batched renders.
Stale Closures in useEffect: Why Your Interval Fires the Same Count Forever
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.
StaleInterval.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — javascript tutorialimport { useState, useEffect, useRef } from'react';
exportdefaultfunctionPollingCounter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// Keep ref in sync with stateuseEffect(() => { 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 oncereturn <div>Count: {count}</div>;
}
Output
// Console output after 3 seconds:
// Tick: 0
// Tick: 1
// Tick: 2
Production Trap:
Adding 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.
Key Takeaway
Never read state inside a setInterval or addEventListener callback without using a ref or functional update — the callback captures stale values.
useEffect Cleanup: The Silent Crash That Only Happens in Production
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 controller.abort() in cleanup. For subscriptions, call .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.
// Previous request is aborted before the new one starts.
// No 'setState on unmounted component' warning.
Senior Shortcut:
If your effect can't be aborted, use a 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.
Key Takeaway
Every useEffect that has an async operation MUST return a cleanup function that cancels the work. No exceptions.
Dependency Arrays Aren't Optional: How Missing Deps Cause Silent Data Loss
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.
// Console: no error, no fetch — silent data loss.
Production Trap:
The 'eslint-disable-next-line' comment for missing deps is a code smell. If your team allows it, you will ship stale data. Require a written reason in the comment, reviewed by a senior.
Key Takeaway
A useEffect with a missing dependency is a guaranteed production bug. Respect exhaustive-deps or document why you're overriding it with a survival plan.
The Pitfall of Async Operations in useEffect: Race Conditions Will Eat Your Data
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.
// When userId changes quickly, only the latest response updates state.
// Cleanup prevents stale setState calls.
Production Trap:
The React 'Can't perform a React state update on an unmounted component' warning is a symptom. The real bug is corrupted data from the wrong response winning the race.
Key Takeaway
Always guard async state updates in useEffect with a cleanup flag to abort the race.
The Hidden Cost of Object References in Dependency Arrays
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.
ObjectDepBug.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — javascript tutorialimport { useEffect, useState } from'react';
functionSearchForm({ initialFilters }) {
const [results, setResults] = useState([]);
// BAD: object literal creates new reference every renderuseEffect(() => {
fetchSearch(initialFilters).then(setResults);
}, [initialFilters]); // ⚠️ This runs every render// FIX: destructure to primitivesconst { term, category } = initialFilters;
useEffect(() => {
fetchSearch({ term, category }).then(setResults);
}, [term, category]); // ✅ Only runs when values changereturn <div>{results.length} results</div>;
}
Output
// With object dep: effect fires on every parent render.
// With primitives: effect fires only when term or category actually changes.
Senior Shortcut:
Use useMemo to stabilize object references when you genuinely need the full object in deps. Otherwise, destructure to primitives and let your linter guide you.
Key Takeaway
Objects and arrays in dependency arrays trigger re-renders by reference — always use primitives or memoized values.
Key Takeaways
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.
// Prevents setting state from an outdated fetch response.
// The cleanup flag 'cancelled' stops race conditions dead.
Production Trap:
Skipping the cleanup flag in async effects is the root cause of intermittent crashes in fast-refresh dev environments. Always abort stale work.
Key Takeaway
Every async effect needs a cancellation mechanism to prevent race conditions.
useContext()
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.
// Only Toolbar re-renders when theme changes. The 'value' memo prevents
excess re-renders from a new object reference each render.
Production Trap:
Passing an inline object to Provider creates a new reference every render, triggering re-renders in every consumer. Always wrap with useMemo.
Key Takeaway
Memoize context values to avoid cascading re-renders across your entire tree.
● 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
Aspect
useState
useEffect
Primary purpose
Store and update data that triggers re-renders
Run side effects in response to renders or state changes
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
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.
Q02 of 04SENIOR
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?
ANSWER
Use the functional updater form: setCount(prevCount => prevCount + 1). This ensures each update uses the most recent state value, even if multiple setCount calls are batched. Without this, if you call setCount(count + 1) from two rapid clicks, both see the same initial count and the counter only increments by 1 instead of 2. The functional form guarantees correct batching behavior.
Q03 of 04SENIOR
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?
ANSWER
No, this is not a bug. React 18 Strict Mode deliberately unmounts and remounts each component in development to surface bugs caused by missing cleanup functions. If your effect runs twice and the second execution causes problems (like duplicate API calls or duplicate subscriptions), it means your effect is missing a cleanup function. The correct fix is to return a cleanup function that cancels the fetch (using AbortController) or unsubscribes. The double run does not happen in production, so your code must handle it gracefully. Always implement cleanup, even if you think it's not needed.
Q04 of 04SENIOR
How would you build a custom hook that tracks the previous value of a prop or state?
ANSWER
Create a custom hook that uses useRef to store the current value and returns the stored value before update. For example: function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current; } The ref holds the value from the previous render. This relies on the effect running after the render — so ref.current is always one render behind.
01
What's the difference between passing no dependency array and passing an empty array [] to useEffect, and why does it matter?
SENIOR
02
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?
SENIOR
03
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?
SENIOR
04
How would you build a custom hook that tracks the previous value of a prop or state?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
Can I call useState and useEffect inside a conditional or loop?
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.
Was this helpful?
02
Why does my useEffect run twice on mount in React 18?
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.
Was this helpful?
03
Should I use one useState call with an object for related state, or multiple useState calls?
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.
Was this helpful?
04
How do I test a custom hook?
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.