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.is comparison, 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

  1. The Three Dependency Configurations
  2. How React Compares Dependencies
  3. Object.is in Detail
  4. Primitive Dependencies
  5. Reference Type Dependencies
  6. Why Infinite Loops Happen
  7. Fixing Every Infinite Loop Pattern
  8. Function Dependencies
  9. Object and Array Dependencies
  10. The exhaustive-deps Rule
  11. When to Suppress the Linter (Almost Never)
  12. Advanced: Effect Events Pattern
  13. Common Dependency Scenarios
  14. Debugging Dependency Issues
  15. 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

ConfigurationRuns on MountRuns on Re-renderCleanup on UnmountCommon Use Case
No array✅ Every timeDebugging, rarely in production
[]❌ NeverOne-time setup (analytics, global listener)
[a, b]✅ When a or b changesMost 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 AValue BObject.is ResultWhy
4242trueSame primitive
"abc""abc"trueSame string
truetruetrueSame boolean
nullnulltrueSame special value
NaNNaNtrueSpecial case (unlike ===)
{ a: 1 }{ a: 1 }falseDifferent references
[1, 2][1, 2]falseDifferent references
() => {}() => {}falseDifferent references
objobjtrueSame 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

TypeExampleWhen 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 LocationReads Props/State?Solution
Inside componentYesMove inside effect or useCallback
Inside componentNoMove outside component
Outside componentNoNo dependency needed
From propsYes (indirectly)Include in deps (parent should memoize)
From contextYes (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

SituationSuppress?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⚠️ MaybeuseRef flag
Buggy third-party library⚠️ MaybeReport bug upstream
Complex non-React integration⚠️ MaybeDocument 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

  1. Three configurations: No array (every render), empty array (once), with deps (when deps change). Choose the right one.

  2. Object.is comparison: React uses Object.is for each dependency. Primitives are safe; objects/arrays/functions need special handling.

  3. Reference equality is the #1 trap. Two objects with identical content are false under Object.is if they're different references.

  4. Infinite loops happen when: missing dep array, object/function in deps (new ref each render), or setting state that's in deps.

  5. Fix strategies: Move inside effect, destructure to primitives, useMemo/useCallback, or updater functions.

  6. Trust the exhaustive-deps linter. Suppressing it almost always means you're hiding a bug.

  7. Functions in deps: Move inside effect (preferred), wrap with useCallback (when shared), or move outside component (pure functions).

  8. Debug with logging: Compare previous and current dep references to find which one is changing unexpectedly.


Explain-It Challenge

  1. 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.

  2. 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?

  3. The Linter Argument: A developer on your team always suppresses exhaustive-deps warnings 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