Episode 2 — React Frontend Architecture NextJS / 2.8 — useEffect Deep Dive
2.8.b — Dependency Array Behaviour
In one sentence: The dependency array tells React when to re-synchronise your effect — understanding
Object.iscomparison, reference equality, and common pitfalls is the difference between effects that work and effects that loop infinitely.
Navigation: ← What useEffect Really Does · Next → Cleanup Functions
Table of Contents
- The Three Dependency Configurations
- How React Compares Dependencies
- Object.is in Detail
- Primitive Dependencies
- Reference Type Dependencies
- Why Infinite Loops Happen
- Fixing Every Infinite Loop Pattern
- Function Dependencies
- Object and Array Dependencies
- The exhaustive-deps Rule
- When to Suppress the Linter (Almost Never)
- Advanced: Effect Events Pattern
- Common Dependency Scenarios
- Debugging Dependency Issues
- Key Takeaways
1. The Three Dependency Configurations
// CONFIG 1: No dependency array
// "Re-synchronise after EVERY render"
useEffect(() => {
console.log('Runs after every single render');
});
// CONFIG 2: Empty dependency array
// "Synchronise once, clean up on unmount"
useEffect(() => {
console.log('Runs once after first render');
return () => console.log('Runs on unmount');
}, []);
// CONFIG 3: With dependencies
// "Re-synchronise when any dependency changes"
useEffect(() => {
console.log(`roomId changed to: ${roomId}`);
return () => console.log(`Cleaning up for: ${roomId}`);
}, [roomId, serverUrl]);
Visual Timeline
State: count=0, name="Alice"
CONFIG 1: No deps array
Render 1 (mount) → Effect runs ✓
Render 2 (count=1) → Effect runs ✓
Render 3 (name="Bob") → Effect runs ✓
Every render triggers re-sync
CONFIG 2: Empty []
Render 1 (mount) → Effect runs ✓
Render 2 (count=1) → Skipped ✗
Render 3 (name="Bob") → Skipped ✗
Only first render triggers sync
CONFIG 3: [count]
Render 1 (mount) → Effect runs ✓
Render 2 (count=1) → Effect runs ✓ (count changed)
Render 3 (name="Bob") → Skipped ✗ (count didn't change)
Only runs when count changes
Comparison Table
| Configuration | Runs on Mount | Runs on Re-render | Cleanup on Unmount | Common Use Case |
|---|---|---|---|---|
| No array | ✅ | ✅ Every time | ✅ | Debugging, rarely in production |
[] | ✅ | ❌ Never | ✅ | One-time setup (analytics, global listener) |
[a, b] | ✅ | ✅ When a or b changes | ✅ | Most effects (fetch, subscription, sync) |
2. How React Compares Dependencies
React compares each dependency with its value from the previous render using Object.is:
// Simplified React internal logic
function shouldRunEffect(prevDeps, nextDeps) {
// No deps array → always run
if (nextDeps === undefined) return true;
// Different lengths (shouldn't happen, but safety)
if (prevDeps.length !== nextDeps.length) return true;
// Compare each dependency
for (let i = 0; i < nextDeps.length; i++) {
if (!Object.is(prevDeps[i], nextDeps[i])) {
return true; // At least one dep changed → re-run
}
}
return false; // All deps same → skip
}
The Comparison Process
Previous render deps: [42, "hello", userObj]
Current render deps: [42, "hello", userObj]
│ │ │
▼ ▼ ▼
Object.is(42, 42) Object.is( Object.is(
→ true ✓ "hello", prevUserObj,
"hello") nextUserObj)
→ true ✓ → ??? depends on reference!
3. Object.is in Detail
Object.is is almost identical to === with two exceptions:
// Same as ===
Object.is(42, 42) // true
Object.is('hello', 'hello') // true
Object.is(true, true) // true
Object.is(null, null) // true
Object.is(undefined, undefined) // true
// Different from ===
Object.is(NaN, NaN) // true (=== gives false!)
Object.is(+0, -0) // false (=== gives true!)
// Reference types — SAME as ===
const obj = { a: 1 };
Object.is(obj, obj) // true (same reference)
Object.is({ a: 1 }, { a: 1 }) // false (different references!)
Object.is([], []) // false
Object.is(() => {}, () => {}) // false
The Reference Equality Trap
This is the #1 source of useEffect bugs:
function UserProfile({ userId }) {
// ❌ BUG: New object on every render → effect runs every render!
const options = { includeAvatar: true, format: 'full' };
useEffect(() => {
fetchUser(userId, options);
}, [userId, options]); // options is a NEW reference each render
// Object.is({ includeAvatar: true }, { includeAvatar: true })
// → false! Different objects in memory!
}
Quick Reference: Object.is Results
| Value A | Value B | Object.is Result | Why |
|---|---|---|---|
42 | 42 | true | Same primitive |
"abc" | "abc" | true | Same string |
true | true | true | Same boolean |
null | null | true | Same special value |
NaN | NaN | true | Special case (unlike ===) |
{ a: 1 } | { a: 1 } | false | Different references |
[1, 2] | [1, 2] | false | Different references |
() => {} | () => {} | false | Different references |
obj | obj | true | Same reference |
4. Primitive Dependencies
Primitives (numbers, strings, booleans, null, undefined) are compared by value. They're safe and predictable:
function SearchResults({ query, page, isActive }) {
useEffect(() => {
if (isActive) {
fetchResults(query, page);
}
}, [query, page, isActive]);
// ✅ All primitives — compared by value
// Only re-runs when query string, page number, or isActive boolean changes
}
Primitive Dependency Table
| Type | Example | When Effect Re-runs |
|---|---|---|
number | [count] | When count value changes (0 → 1) |
string | [name] | When name string changes ("Alice" → "Bob") |
boolean | [isOpen] | When isOpen flips (true → false) |
null | [selected] | When selected goes from null to a value or vice versa |
undefined | [data] | When data appears or disappears |
The useState Setter Stability
useState setters are stable across renders — you never need to include them:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // setCount is stable
}, 1000);
return () => clearInterval(id);
}, []); // ✅ No need to include setCount — it never changes
}
5. Reference Type Dependencies
Objects, arrays, and functions are compared by reference. This is where most bugs live.
The Problem Visualised
Render 1:
const config = { theme: "dark" }; // Object #A in memory
useEffect(() => {...}, [config]); // Stores reference to #A
Render 2:
const config = { theme: "dark" }; // Object #B in memory (NEW!)
useEffect(() => {...}, [config]); // Compares #A vs #B
// Object.is(#A, #B) → false
// Effect re-runs! (unnecessary)
Even though the CONTENT is identical,
the REFERENCES are different.
Solution 1: Move Object Inside Effect
// ❌ Object created every render
function Chat({ roomId }) {
const options = { serverUrl: 'https://chat.app', roomId };
useEffect(() => {
const connection = connect(options);
return () => connection.disconnect();
}, [options]); // New reference every render!
}
// ✅ Object created inside effect
function Chat({ roomId }) {
useEffect(() => {
const options = { serverUrl: 'https://chat.app', roomId };
const connection = connect(options);
return () => connection.disconnect();
}, [roomId]); // Only primitive dep!
}
Solution 2: Destructure to Primitives
// ❌ Object dependency
function Profile({ user }) {
useEffect(() => {
document.title = `${user.name}'s Profile`;
}, [user]); // user object changes reference on every parent render
}
// ✅ Destructure to primitives
function Profile({ user }) {
const { name } = user;
useEffect(() => {
document.title = `${name}'s Profile`;
}, [name]); // string comparison — stable!
}
Solution 3: useMemo for Stable References
// ✅ Memoize the object
function DataGrid({ rows, sortConfig }) {
const processedRows = useMemo(
() => sortRows(rows, sortConfig),
[rows, sortConfig]
);
useEffect(() => {
renderChart(processedRows);
}, [processedRows]); // Stable reference (only changes when rows/sortConfig change)
}
6. Why Infinite Loops Happen
The Classic Infinite Loop
// ❌ INFINITE LOOP
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data)); // Sets state...
}); // No deps → runs after EVERY render
// Render → effect → setUsers → re-render → effect → setUsers → ...
}
The Object Reference Loop
// ❌ INFINITE LOOP
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const params = { id: userId, include: 'avatar' }; // New object every render!
useEffect(() => {
fetchUser(params).then(setUser);
}, [params]); // params always "changed" → always re-runs
// Render → effect (new params ref) → fetchUser → setUser →
// re-render → effect (new params ref) → fetchUser → setUser → ...
}
The State-in-Deps Loop
// ❌ INFINITE LOOP
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // Updates state that's in deps...
}, [count]); // count changed → re-run → count changed → re-run → ...
}
The Inline Function Loop
// ❌ INFINITE LOOP
function Search({ query }) {
const [results, setResults] = useState([]);
// New function on every render
const fetchResults = () => {
return fetch(`/api/search?q=${query}`).then(r => r.json());
};
useEffect(() => {
fetchResults().then(setResults);
}, [fetchResults]); // New reference every render!
}
Infinite Loop Detection Diagram
Is your effect looping?
│
├─ Missing dependency array?
│ └─ Add [] or [deps]
│
├─ Object/array/function in deps?
│ └─ New reference every render → move inside effect or useMemo
│
├─ Setting state that's in deps?
│ └─ Use updater function or remove from deps
│
└─ Setting state that causes parent re-render → new props → re-run?
└─ Memoize in parent or restructure component tree
7. Fixing Every Infinite Loop Pattern
Pattern 1: Missing Dependency Array → Add It
// ❌ Loops forever
useEffect(() => {
fetchData().then(setData);
});
// ✅ Runs once
useEffect(() => {
fetchData().then(setData);
}, []);
Pattern 2: Object in Deps → Move Inside or Destructure
// ❌ Loops (new object each render)
const config = { url: apiUrl, method: 'GET' };
useEffect(() => {
fetch(config.url, { method: config.method });
}, [config]);
// ✅ Fix: Move inside
useEffect(() => {
const config = { url: apiUrl, method: 'GET' };
fetch(config.url, { method: config.method });
}, [apiUrl]); // primitive dep
// ✅ Fix: Destructure
const { url, method } = config;
useEffect(() => {
fetch(url, { method });
}, [url, method]); // primitive deps
Pattern 3: Setting State That's in Deps → Updater Function
// ❌ Loops
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // Reads count from closure
}, 1000);
return () => clearInterval(id);
}, [count]); // count changes → cleanup → new interval → repeat
// ✅ Fix: Updater function doesn't read count
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // Doesn't read count
}, 1000);
return () => clearInterval(id);
}, []); // No deps needed!
Pattern 4: Function in Deps → useCallback or Move Inside
// ❌ Loops
function SearchPage({ query }) {
const [results, setResults] = useState([]);
function getResults() {
return fetch(`/api/search?q=${query}`);
}
useEffect(() => {
getResults().then(r => r.json()).then(setResults);
}, [getResults]); // New function every render!
}
// ✅ Fix A: Move function inside effect
useEffect(() => {
function getResults() {
return fetch(`/api/search?q=${query}`);
}
getResults().then(r => r.json()).then(setResults);
}, [query]); // Depends on query, not the function
// ✅ Fix B: useCallback
const getResults = useCallback(() => {
return fetch(`/api/search?q=${query}`);
}, [query]);
useEffect(() => {
getResults().then(r => r.json()).then(setResults);
}, [getResults]); // Only changes when query changes
Pattern 5: Array in Deps → JSON.stringify or useMemo
// ❌ Loops (new array every render)
function TagFilter({ tags }) {
// tags = ["react", "hooks"] — new array from parent each render
useEffect(() => {
fetchByTags(tags);
}, [tags]); // New reference!
}
// ✅ Fix A: JSON.stringify (simple cases)
const tagsKey = JSON.stringify(tags);
useEffect(() => {
fetchByTags(tags);
}, [tagsKey]); // String comparison — stable
// ✅ Fix B: useMemo in parent
// Parent:
const tags = useMemo(() => ["react", "hooks"], []);
// Now the reference is stable
8. Function Dependencies
Functions are the trickiest dependency type.
Rule: If a Function Reads Props/State, It Belongs in Deps
function SearchResults({ query }) {
// This function reads `query` from props
function fetchResults() {
return fetch(`/api/search?q=${query}`);
}
useEffect(() => {
fetchResults().then(r => r.json()).then(setResults);
// eslint wants: [fetchResults]
// But fetchResults is new every render!
}, [fetchResults]);
}
The Three Strategies
// STRATEGY 1: Move function inside effect (PREFERRED)
useEffect(() => {
function fetchResults() {
return fetch(`/api/search?q=${query}`);
}
fetchResults().then(r => r.json()).then(setResults);
}, [query]); // Now depends on query directly
// STRATEGY 2: useCallback (when function is shared)
const fetchResults = useCallback(() => {
return fetch(`/api/search?q=${query}`);
}, [query]);
// Used in effect AND event handler
useEffect(() => {
fetchResults().then(r => r.json()).then(setResults);
}, [fetchResults]);
function handleRefresh() {
fetchResults().then(r => r.json()).then(setResults);
}
// STRATEGY 3: Move function outside component (pure functions)
// Works when function doesn't use props/state
function formatDate(date) {
return new Intl.DateTimeFormat('en').format(date);
}
function Timeline({ events }) {
useEffect(() => {
const formatted = events.map(e => formatDate(e.date));
// ...
}, [events]); // formatDate is outside, not a dep
}
Decision Table for Function Dependencies
| Function Location | Reads Props/State? | Solution |
|---|---|---|
| Inside component | Yes | Move inside effect or useCallback |
| Inside component | No | Move outside component |
| Outside component | No | No dependency needed |
| From props | Yes (indirectly) | Include in deps (parent should memoize) |
| From context | Yes (indirectly) | Include in deps (context should provide stable refs) |
9. Object and Array Dependencies
The Problem
function MapView({ center, markers }) {
// center = { lat: 40.7, lng: -74.0 } — new object from parent
// markers = [{ id: 1, pos: ... }] — new array from parent
useEffect(() => {
map.setCenter(center);
map.setMarkers(markers);
}, [center, markers]); // Both are new references every render!
}
Strategy 1: Destructure to Primitives
function MapView({ center, markers }) {
const { lat, lng } = center;
useEffect(() => {
map.setCenter({ lat, lng });
}, [lat, lng]); // Primitives — stable!
}
Strategy 2: useMemo in Parent
// Parent component
function App() {
const center = useMemo(() => ({ lat: 40.7, lng: -74.0 }), []);
const markers = useMemo(() => [
{ id: 1, position: { lat: 40.71, lng: -74.01 } },
], []);
return <MapView center={center} markers={markers} />;
}
Strategy 3: JSON.stringify for Simple Structures
function FilteredList({ filters }) {
// filters = { status: "active", sort: "name" }
const filtersKey = JSON.stringify(filters);
useEffect(() => {
const parsedFilters = JSON.parse(filtersKey);
fetchItems(parsedFilters);
}, [filtersKey]); // String comparison
}
Strategy 4: Custom Comparison Hook
function useDeepCompareEffect(callback, dependencies) {
const previousDepsRef = useRef(dependencies);
if (!deepEqual(previousDepsRef.current, dependencies)) {
previousDepsRef.current = dependencies;
}
useEffect(callback, [previousDepsRef.current]);
}
// Usage
function MapView({ config }) {
useDeepCompareEffect(() => {
map.configure(config);
}, [config]); // Deep comparison instead of reference
}
// NOTE: Use this sparingly — it hides re-render issues
10. The exhaustive-deps Rule
The react-hooks/exhaustive-deps ESLint rule enforces that every value used inside useEffect is listed in the dependency array.
What It Catches
// WARNING: React Hook useEffect has a missing dependency: 'count'
useEffect(() => {
document.title = `Count: ${count}`;
}, []); // Missing count!
// WARNING: React Hook useEffect has a missing dependency: 'fetchData'
useEffect(() => {
fetchData(userId);
}, [userId]); // Missing fetchData!
// WARNING: React Hook useEffect has an unnecessary dependency: 'name'
useEffect(() => {
console.log(count);
}, [count, name]); // name is not used in effect
Why You Should (Almost) Never Suppress It
// ❌ DON'T: Suppress the warning
useEffect(() => {
fetchUser(userId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // "I only want this on mount"
// WHY THIS IS BAD:
// If userId changes (e.g., user switches accounts),
// the effect won't re-run and you'll show stale data!
// ✅ DO: Include the dependency
useEffect(() => {
fetchUser(userId);
}, [userId]); // Re-fetches when userId changes — correct behavior!
The Linter Is Smarter Than You Think
The exhaustive-deps rule prevents subtle bugs:
// BUG WITHOUT THE RULE:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket(`wss://chat.app/${roomId}`);
ws.onmessage = (e) => setMessages(prev => [...prev, e.data]);
return () => ws.close();
}, []); // "I only want to connect once"
// BUG: If roomId changes (user switches room),
// we're still connected to the OLD room!
}
// CORRECT:
useEffect(() => {
const ws = new WebSocket(`wss://chat.app/${roomId}`);
ws.onmessage = (e) => setMessages(prev => [...prev, e.data]);
return () => ws.close();
}, [roomId]); // Reconnects when room changes — correct!
11. When to Suppress the Linter (Almost Never)
There are very few legitimate reasons to suppress exhaustive-deps:
Legitimate Case 1: Intentionally Running Once with Props
// Analytics: Log the page that was first viewed
useEffect(() => {
analytics.logFirstView(pageId);
// We intentionally want the FIRST pageId only
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Better alternative: use a ref to track if we've already logged:
const hasLoggedRef = useRef(false);
useEffect(() => {
if (!hasLoggedRef.current) {
analytics.logFirstView(pageId);
hasLoggedRef.current = true;
}
}, [pageId]); // No suppression needed!
Legitimate Case 2: Third-Party Library with Unstable API
// Some libraries return new references every render
useEffect(() => {
thirdPartyLib.init(unstableConfig);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unstableConfig.id]); // Only care about the ID changing
The Decision Table
| Situation | Suppress? | Better Alternative |
|---|---|---|
| "I only want this on mount" | ❌ | Include deps — it should re-run when they change |
| "This function is stable" | ❌ | Wrap with useCallback |
| "This object never changes" | ❌ | useMemo or move outside |
| Analytics first-view only | ⚠️ Maybe | useRef flag |
| Buggy third-party library | ⚠️ Maybe | Report bug upstream |
| Complex non-React integration | ⚠️ Maybe | Document why clearly |
12. Advanced: Effect Events Pattern
React is working on a pattern called "effect events" (experimental as of 2025) that addresses a common pain point:
The Problem: Non-Reactive Values in Effects
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
// We want to read theme here, but don't want to
// reconnect when theme changes!
showNotification(`Connected to ${roomId}`, theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // theme forces reconnect — wrong!
}
Current Workaround: useRef for Latest Value
function ChatRoom({ roomId, theme }) {
const themeRef = useRef(theme);
themeRef.current = theme; // Always has latest value
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
// Read latest theme from ref — no dependency needed
showNotification(`Connected to ${roomId}`, themeRef.current);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // Only reconnects when room changes ✅
}
Custom Hook: useEffectEvent
// Reusable hook for "latest value without deps"
function useEffectEvent(fn) {
const ref = useRef(fn);
ref.current = fn;
return useCallback((...args) => ref.current(...args), []);
}
// Usage
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification(`Connected to ${roomId}`, theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // Only reactive dependency
}
13. Common Dependency Scenarios
Scenario 1: Fetching Data Based on Props
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(userId).then(data => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [userId]); // ✅ Re-fetches when userId changes
}
Scenario 2: Subscribing to External Data Source
function StockPrice({ symbol }) {
const [price, setPrice] = useState(null);
useEffect(() => {
const ws = new WebSocket(`wss://stocks.api/${symbol}`);
ws.onmessage = (event) => {
setPrice(JSON.parse(event.data).price);
};
return () => ws.close();
}, [symbol]); // ✅ Reconnects when symbol changes
}
Scenario 3: Setting Up a Timer with Dynamic Interval
function Poller({ interval }) {
useEffect(() => {
const id = setInterval(() => {
fetchLatestData(); // Assume this is stable/outside
}, interval);
return () => clearInterval(id);
}, [interval]); // ✅ Resets timer when interval changes
}
Scenario 4: Syncing with localStorage
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]); // ✅ Syncs when key or value changes
return [value, setValue];
}
Scenario 5: Multiple Dependencies with Conditional Logic
function SearchResults({ query, filters, isEnabled }) {
const [results, setResults] = useState([]);
useEffect(() => {
if (!isEnabled || !query) return; // Guard clause
const controller = new AbortController();
fetch(`/api/search?q=${query}&${new URLSearchParams(filters)}`, {
signal: controller.signal
})
.then(r => r.json())
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, [query, filters, isEnabled]);
// Note: if filters is an object, use JSON.stringify or useMemo
}
14. Debugging Dependency Issues
Technique 1: Log Previous and Current Deps
function useEffectDebug(effect, deps, label = 'Effect') {
const prevDepsRef = useRef(deps);
useEffect(() => {
const changedDeps = deps.reduce((acc, dep, i) => {
if (!Object.is(dep, prevDepsRef.current[i])) {
acc.push({
index: i,
previous: prevDepsRef.current[i],
current: dep,
});
}
return acc;
}, []);
if (changedDeps.length > 0) {
console.log(`[${label}] Dependencies changed:`, changedDeps);
}
prevDepsRef.current = deps;
return effect();
}, deps);
}
// Usage
useEffectDebug(
() => {
fetchData(userId, config);
},
[userId, config],
'FetchData'
);
// Console: [FetchData] Dependencies changed: [{ index: 1, previous: {...}, current: {...} }]
Technique 2: React DevTools Profiler
1. Open React DevTools → Profiler tab
2. Record a session
3. Look for components re-rendering unexpectedly
4. Check "Why did this render?" for each render
5. Effect re-runs correlate with component re-renders + dep changes
Technique 3: Temporary Dep Isolation
// Which dep is causing re-runs? Test each one:
useEffect(() => {
console.log('Effect ran');
fetchData(userId, options);
}, [userId]); // First test with just userId
// If it stops looping, the problem is `options`!
Common Debugging Checklist
Effect running too often?
□ Check each dep: is it a new reference each render?
□ Object/array dep? → useMemo or destructure
□ Function dep? → useCallback or move inside
□ Parent re-rendering? → React.memo or lift state
□ Context changing? → Split context
Effect not running when expected?
□ Is the dep actually changing? (log it)
□ Object.is might consider it "same" (NaN, same reference)
□ State update with same value? (React skips render)
□ Missing from dependency array? (check linter)
15. Key Takeaways
-
Three configurations: No array (every render), empty array (once), with deps (when deps change). Choose the right one.
-
Object.is comparison: React uses
Object.isfor each dependency. Primitives are safe; objects/arrays/functions need special handling. -
Reference equality is the #1 trap. Two objects with identical content are
falseunder Object.is if they're different references. -
Infinite loops happen when: missing dep array, object/function in deps (new ref each render), or setting state that's in deps.
-
Fix strategies: Move inside effect, destructure to primitives, useMemo/useCallback, or updater functions.
-
Trust the exhaustive-deps linter. Suppressing it almost always means you're hiding a bug.
-
Functions in deps: Move inside effect (preferred), wrap with useCallback (when shared), or move outside component (pure functions).
-
Debug with logging: Compare previous and current dep references to find which one is changing unexpectedly.
Explain-It Challenge
-
The Address Analogy: Two houses at different addresses can look identical inside, but they're different houses. Explain how this relates to Object.is comparing objects in dependency arrays.
-
Breaking the Loop: Your colleague has an infinite loop in their useEffect. Walk them through your systematic debugging process — what do you check first, second, third?
-
The Linter Argument: A developer on your team always suppresses
exhaustive-depswarnings because "I know when my effect should run." Explain why the linter usually knows better, using a specific example of a bug that suppression would hide.
Navigation: ← What useEffect Really Does · Next → Cleanup Functions