useState stores values outside render so they survive re-renders; calling the setter schedules a future render, it doesn't mutate the variable.
useEffect runs after the browser paints — never use it to compute values for the current render; use inline calculation or useMemo.
The dependency array is a watch list: empty [] = run once; missing array = run every render; array with values = run when those change.
Always return a cleanup function from any effect that sets up a timer, subscription, or fetch — missing cleanup leads to memory leaks and stale state updates.
Plain-English First
Imagine your React component is a whiteboard in a classroom. useState is the marker — it lets you write something on the board and erase it when things change. useEffect is the teacher who walks in after every change and says 'OK, something changed — let me react to that.' Without useState, the board never updates. Without useEffect, nobody notices when it does.
Every interactive UI you've ever used — a loading spinner, a live search bar, a tweet that updates its like count without a page refresh — relies on two things: state that changes over time, and side effects that respond to those changes. React's useState and useEffect hooks are the engine behind all of it. They're not just syntax sugar. They represent a fundamental shift in how we think about building UIs: as a function of state, not as a series of DOM mutations.
Before hooks arrived in React 16.8, you had to write class components just to store a piece of state or fire an API call after render. That meant lifecycle methods like componentDidMount and componentDidUpdate scattered across large files, making logic hard to co-locate and even harder to reuse. Hooks solved this by letting you drop stateful logic directly into a function component — keeping related code together and making it trivially portable as a custom hook.
By the end of this article you'll understand not just how to call useState and useEffect, but when each one is the right tool, what happens under the hood on each render, and the three most common bugs developers write with these hooks — plus exactly how to fix them. You'll also walk away with real interview-ready answers that go beyond reciting the docs.
useState — How React Remembers Things Between Renders
Every time React re-renders a component, it calls that function again from scratch. Local variables get thrown away. So how does a counter remember its count? That's the problem useState solves.
When you call useState(initialValue), React stores that value outside your component in a special internal cell tied to that specific hook call position. On the next render, instead of re-initialising from scratch, React hands you back whatever value is currently in that cell. You also get a setter function — when you call it, React schedules a re-render and updates the cell before the next one runs.
This is why state updates feel asynchronous. You're not mutating a variable. You're scheduling a future render with new data. That distinction is critical — it explains several gotchas we'll cover later.
Use useState whenever a piece of data needs to trigger a visual update when it changes. If a value changes but you don't need the UI to re-render, a plain ref (useRef) is usually the better tool.
PasswordStrengthChecker.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { useState } from'react';
// A real-world example: a password strength 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
● 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.