Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

React Hooks

useEffect & Side Effects

Side effects are operations that reach outside a component: API calls, timers, DOM manipulation, logging, etc. The useEffect hook is how React handles these operations in functional components. Real-world analogy: Think of a React component as a chef who only cooks (renders JSX). Side effects are everything that is not cooking — ordering ingredients (API calls), setting a kitchen timer (setTimeout), tuning the radio (event listeners). The chef cannot do these things while plating a dish, so they happen after the dish is served. That is exactly when useEffect runs — after the component renders and the DOM updates. And just like a responsible chef cleans up after cooking, the cleanup function in useEffect tears down timers, unsubscribes from listeners, and cancels pending requests.

What is a Side Effect?

Pure Component (no side effects):
┌─────────────────────────────────────────────┐
│  Props + State  ──────►  JSX Output         │
│                                             │
│  Same inputs = Same output (predictable)    │
└─────────────────────────────────────────────┘

Component with Side Effects:
┌─────────────────────────────────────────────┐
│  Props + State  ──────►  JSX Output         │
│                             │               │
│                    ┌────────▼────────┐      │
│                    │  Side Effects   │      │
│                    │  • API calls    │      │
│                    │  • Timers       │      │
│                    │  • DOM updates  │      │
│                    │  • Subscriptions│      │
│                    └─────────────────┘      │
└─────────────────────────────────────────────┘

Basic useEffect Syntax

import { useEffect } from 'react';

useEffect(() => {
  // Effect code runs here
  
  return () => {
    // Optional cleanup function
  };
}, [dependencies]); // Dependency array

The Dependency Array

The dependency array controls when your effect runs:

1. No Dependency Array — Runs After Every Render

useEffect(() => {
  console.log('Runs after EVERY render');
});
This is rarely what you want! It can cause performance issues and infinite loops.

2. Empty Array — Runs Once on Mount

useEffect(() => {
  console.log('Runs only on mount (like componentDidMount)');
}, []);

3. With Dependencies — Runs When Dependencies Change

useEffect(() => {
  console.log(`Count changed to: ${count}`);
}, [count]); // Runs on mount AND when count changes

Visual Guide

function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  // ❌ Runs on EVERY render
  useEffect(() => {
    document.title = `Count: ${count}`;
  });

  // ✅ Runs once on mount
  useEffect(() => {
    fetchInitialData();
  }, []);

  // ✅ Runs when count changes
  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

  // ✅ Runs when count OR name changes
  useEffect(() => {
    console.log({ count, name });
  }, [count, name]);
}

Cleanup Functions

Some effects need cleanup: unsubscribing, clearing timers, removing listeners. If you start something, you need to stop it — otherwise you get memory leaks and ghost subscriptions that keep running after the component is gone.
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // Effect: Start a timer that increments every second.
    // Note the functional update (s => s + 1) -- this avoids
    // a stale closure over `seconds`. Without it, the interval
    // would always read `seconds` as 0.
    const intervalId = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    // Cleanup: Clear the timer when the component unmounts.
    // Without this, the interval keeps running in the background,
    // trying to call setSeconds on an unmounted component.
    return () => {
      clearInterval(intervalId);
    };
  }, []); // Empty array: run effect once on mount, cleanup on unmount

  return <p>Seconds: {seconds}</p>;
}
Forgetting cleanup is one of the most common React bugs. Symptoms include: “Can’t perform a React state update on an unmounted component” warnings, memory usage growing over time, and event handlers firing for components that no longer exist. If your effect sets up a subscription, timer, or listener, always return a cleanup function.

When Cleanup Runs

Component mounts:
  └── Effect runs

Dependency changes:
  ├── Previous cleanup runs
  └── Effect runs again

Component unmounts:
  └── Cleanup runs

Event Listener Example

function WindowSize() {
  const [size, setSize] = useState({ 
    width: window.innerWidth, 
    height: window.innerHeight 
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };

    window.addEventListener('resize', handleResize);
    
    // Cleanup
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return <p>Window: {size.width} x {size.height}</p>;
}

Data Fetching

One of the most common use cases for useEffect.

Basic Fetch

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(`/api/users/${userId}`);
        
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // Re-fetch when userId changes

  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Handling Race Conditions

When fetching data based on changing props, you can have race conditions. Imagine a user types “re”, then “rea”, then “reac” quickly. Three fetches fire. If the “re” response arrives after the “reac” response (because the server was slower for that query), you’d show stale results for “re” instead of “reac”. This is a race condition.
function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    // This flag tracks whether this specific effect instance
    // has been superseded by a newer one.
    let isCancelled = false;

    const search = async () => {
      const data = await fetch(`/api/search?q=${query}`);
      const json = await data.json();
      
      // Only update state if this effect is still the "current" one.
      // If the user typed another character while we were waiting,
      // a new effect fired and set isCancelled = true for this one.
      if (!isCancelled) {
        setResults(json);
      }
    };

    search();

    // Cleanup: when query changes, this runs BEFORE the new effect,
    // marking the old fetch as stale so its response is ignored.
    return () => {
      isCancelled = true;
    };
  }, [query]);

  return (
    <ul>
      {results.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

Using AbortController

The proper way to cancel fetch requests:
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const controller = new AbortController();

    const search = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/search?q=${query}`, {
          signal: controller.signal
        });
        const data = await response.json();
        setResults(data);
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('Search failed:', error);
        }
      } finally {
        setLoading(false);
      }
    };

    if (query) {
      search();
    }

    return () => {
      controller.abort();
    };
  }, [query]);

  return (/* ... */);
}

Common Patterns

1. Sync with External System

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
}

2. Update Document Title

function Page({ title }) {
  useEffect(() => {
    const originalTitle = document.title;
    document.title = title;
    
    return () => {
      document.title = originalTitle;
    };
  }, [title]);
}

3. Local Storage Sync

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage
function App() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
}

4. Debounced API Call

function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    const timeoutId = setTimeout(async () => {
      const data = await fetchSearch(query);
      setResults(data);
    }, 300);

    return () => clearTimeout(timeoutId);
  }, [query]);

  return (
    <div>
      <input 
        value={query} 
        onChange={(e) => setQuery(e.target.value)} 
      />
      <ul>
        {results.map(r => <li key={r.id}>{r.name}</li>)}
      </ul>
    </div>
  );
}

5. Intersection Observer (Infinite Scroll)

function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const loaderRef = useRef(null);

  useEffect(() => {
    const fetchItems = async () => {
      const data = await fetch(`/api/items?page=${page}`);
      const newItems = await data.json();
      setItems(prev => [...prev, ...newItems]);
    };
    
    fetchItems();
  }, [page]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          setPage(p => p + 1);
        }
      },
      { threshold: 1.0 }
    );

    if (loaderRef.current) {
      observer.observe(loaderRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div>
      {items.map(item => <ItemCard key={item.id} item={item} />)}
      <div ref={loaderRef}>Loading more...</div>
    </div>
  );
}

useEffect Pitfalls

1. Infinite Loops

// ❌ BAD - Object creates new reference each render
useEffect(() => {
  setData({ count: count + 1 });
}, [data]); // data always "changes"

// ❌ BAD - Missing dependency
const [count, setCount] = useState(0);
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // Stale closure!
  }, 1000);
  return () => clearInterval(id);
}, []); // count is missing

// ✅ FIXED - Use functional update
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

2. Stale Closures

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const handleClick = () => {
      // ❌ This captures the initial count value
      console.log('Count:', count);
    };
    
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, []); // ❌ count not in dependencies

  // ✅ FIXED: Add count to dependencies, or use a ref
}

3. Async Functions

// ❌ BAD - useEffect callback can't be async
useEffect(async () => {
  const data = await fetch('/api/data');
}, []);

// ✅ GOOD - Define async function inside
useEffect(() => {
  const fetchData = async () => {
    const data = await fetch('/api/data');
  };
  fetchData();
}, []);

// ✅ ALSO GOOD - IIFE
useEffect(() => {
  (async () => {
    const data = await fetch('/api/data');
  })();
}, []);

When NOT to Use useEffect

This section is arguably the most important part of this chapter. Over-using useEffect is the single biggest source of bugs and unnecessary complexity in React codebases. The React team themselves have said: “You might not need an effect.” Some things don’t need useEffect:

Transforming Data

// ❌ Don't use effect for derived data
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);

useEffect(() => {
  setFilteredItems(items.filter(i => i.active));
}, [items]);

// ✅ Just calculate it during render
const [items, setItems] = useState([]);
const filteredItems = items.filter(i => i.active);

Handling Events

// ❌ Don't use effect to respond to user actions
const [submitted, setSubmitted] = useState(false);

useEffect(() => {
  if (submitted) {
    sendToServer(formData);
  }
}, [submitted]);

// ✅ Just handle in the event handler
const handleSubmit = () => {
  sendToServer(formData);
};

React Strict Mode and Double Effects

Why your effect runs twice in development: In React 18+ with Strict Mode enabled (the default in Create React App and Vite templates), React intentionally unmounts and remounts every component on initial mount during development. This means your useEffect fires, then the cleanup runs, then the effect fires again.This is not a bug. React does this to help you find effects that are missing cleanup. If your effect breaks when it runs twice (e.g., it adds two event listeners, creates two subscriptions, or appends duplicate DOM elements), that is a sign your cleanup function is missing or incomplete.In production, effects only run once. If the double-fire bothers you during development, fix the cleanup function rather than disabling Strict Mode.

🎯 Practice Exercises

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(url, { signal: controller.signal });
        
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        
        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// Usage
function Users() {
  const { data, loading, error } = useFetch('/api/users');

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  
  return (
    <ul>
      {data.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}
function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const intervalId = setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => clearInterval(intervalId);
  }, []);

  return (
    <div className="clock">
      <div className="time">
        {time.toLocaleTimeString()}
      </div>
      <div className="date">
        {time.toLocaleDateString(undefined, {
          weekday: 'long',
          year: 'numeric',
          month: 'long',
          day: 'numeric'
        })}
      </div>
    </div>
  );
}
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

function App() {
  const isOnline = useOnlineStatus();

  return (
    <div>
      <StatusBadge online={isOnline} />
      {!isOnline && (
        <div className="offline-banner">
          You're offline. Some features may not work.
        </div>
      )}
    </div>
  );
}

Summary

ConceptDescription
Side EffectOperation outside React’s render flow
useEffectHook for managing side effects
No dependenciesRuns after every render (rarely wanted)
Empty []Runs once on mount
[dep1, dep2]Runs when dependencies change
Cleanup functionReturned function for cleanup
Race conditionsHandle with flags or AbortController
Stale closuresUse functional updates or add dependencies
Derived dataCalculate during render, not in effects

Next Steps

In the next chapter, you’ll learn about the Context API — managing global state without prop drilling!

Interview Deep-Dive

Strong Answer: The cleanup function runs in two scenarios: before the effect re-runs (when dependencies change) and when the component unmounts. The exact sequence when a dependency changes is: React renders new output, commits DOM changes, runs the previous effect’s cleanup, then runs the new effect. This ordering matters — cleanup runs with the old props/state values because it is the closure from the previous render.Forgetting cleanup causes three categories of production bugs. First, memory leaks: subscriptions, intervals, and event listeners keep accumulating. I once debugged a dashboard where every navigation to a page added a new WebSocket connection that was never closed. After 50 navigations, there were 50 open sockets, each receiving and processing messages, causing the tab to consume gigabytes of memory and lag severely.Second, state updates on unmounted components: an async fetch completes after the user navigates away, calling setState on a component that no longer exists. React 17 and earlier showed a warning; React 18 silently ignores it, but the unnecessary work still happens.Third, stale event handlers: if you add a window resize listener without cleanup, each re-render adds another listener. After 100 renders, a single resize event calls your handler 100 times. I have seen this cause layout thrashing so severe it dropped the app to 2fps.The mental model I use: if you start something (timer, listener, subscription, connection), you must stop it. If you allocate something (abort controller, observer), you must clean it up. The cleanup function is the “undo” button for whatever the effect did.Follow-up: In React 18 Strict Mode, effects run twice on mount in development. Why does React do this, and what does it expose?React 18 Strict Mode intentionally mounts, unmounts, and remounts components in development to surface missing cleanup bugs. The sequence is: mount (effect runs), unmount (cleanup runs), mount again (effect runs again). If your effect has no cleanup, the second mount creates a duplicate subscription/timer/listener.This behavior does not happen in production. It exists solely as a development-time check for effects that would break in concurrent rendering scenarios, where React might discard and re-execute a render. If your effect does not survive a mount-unmount-remount cycle cleanly, it has a bug that concurrent features (like Suspense) could trigger in production.The practical test: if you see doubled API calls, doubled subscriptions, or doubled timers in development with Strict Mode, your cleanup function is missing or incomplete. Fix the cleanup, and the double-mount becomes harmless because the first instance is properly cleaned up before the second runs.
Strong Answer: The React team themselves say “you might not need an effect,” and I think overuse of useEffect is the single biggest source of unnecessary complexity in React codebases. Here are the patterns I actively watch for in code reviews:Transforming data for display: if you have items in state and need a filtered version, do not put filtered items in a separate state and sync them with useEffect. Just compute it during render: const filtered = items.filter(i => i.active). If the computation is expensive, wrap it in useMemo. Using useEffect creates an extra render cycle and a synchronization bug waiting to happen.Resetting state when a prop changes: do not use useEffect(() => { setLocalState(prop) }, [prop]). This causes a flash of stale state on the first render, then the effect corrects it on the second. Instead, use a key prop to remount the component, or compute the derived value directly. If you genuinely need to reset local state when a prop changes, the key approach is cleanest.Responding to user actions: if a button click should trigger an API call, do it in the onClick handler, not in an effect triggered by a submitted flag. Effects are for synchronizing with external systems, not for running event-driven logic. The event handler is the right place because it has direct access to the context (which button, which form data, whether the user confirmed).Notifying parent components: do not use useEffect(() => { onValueChange(value) }, [value]) to notify a parent of state changes. This creates cascading renders and is hard to trace. Instead, call the callback at the point where the value actually changes (in the event handler or state setter).The rule of thumb: useEffect is for synchronizing React with something external — a DOM API, a third-party library, a WebSocket, a timer. If both the input and the output are within React’s world, you probably do not need an effect.Follow-up: A developer argues that fetching data is not a “side effect” because it is essential to the component’s purpose. Should data fetching use useEffect?Data fetching IS a side effect — it reaches outside the React component to talk to a server. Whether it is “essential” does not change its nature. The question is whether useEffect is the best tool for data fetching, and increasingly the answer is “not directly.”The problems with raw useEffect for data fetching: no built-in caching (refetching on every mount), no deduplication (multiple components fetching the same data), manual loading/error states, race condition handling, no prefetching, and no integration with Suspense.Modern approaches: React Query / TanStack Query gives you caching, background refetching, deduplication, retry logic, and pagination out of the box. Next.js and Remix move fetching to the server via Server Components or loaders, eliminating the client-side effect entirely. React’s use() hook (experimental) integrates with Suspense for data fetching.My recommendation: use useEffect for data fetching in simple apps or learning exercises. For production apps, use React Query or a framework’s built-in data fetching. The useEffect is still there under the hood in these libraries, but they handle all the edge cases you would otherwise implement manually.
Strong Answer: Both solve the same problem — preventing a stale response from overwriting fresh data — but at different levels.The boolean flag approach sets a local variable let isCancelled = false inside the effect. The cleanup function sets isCancelled = true. After the fetch resolves, the handler checks if (!isCancelled) before calling setState. This is simple and works, but the HTTP request still completes on the network. The server does all the work, the response travels back, the browser parses it — you just ignore the result. For expensive server operations or metered APIs, this is wasteful.AbortController actually cancels the request at the network level. You create a controller, pass signal: controller.signal to fetch, and call controller.abort() in the cleanup. The browser cancels the TCP connection, the server may stop processing (if it checks for disconnection), and no response is transferred. This saves bandwidth, reduces server load, and is faster because the browser does not buffer the discarded response.In practice, I always use AbortController when available because the cost is the same as the boolean flag (one extra object creation) but the benefit is real resource savings. The only additional handling needed is catching the AbortError and ignoring it, since aborting causes fetch to reject: catch (err) { if (err.name !== 'AbortError') setError(err.message) }.Where AbortController does not help: XMLHttpRequest-based libraries (older Axios versions), GraphQL subscriptions, or WebSocket messages. For those, the boolean flag is the fallback.Follow-up: You have a search input that fires a fetch on every keystroke. Besides race conditions, what other problems arise and how do you solve them?Three problems beyond race conditions. First, excessive requests: a user typing “react hooks” sends 11 requests (one per character). Use debouncing — delay the fetch by 300ms after the last keystroke. If the user types another character within 300ms, cancel the previous timer. The net effect: one request for “react hooks” instead of 11.Second, rate limiting: your API may throttle at 10 requests per second. Even with debouncing, fast typers or multiple users can hit limits. Add client-side throttling as a safeguard, and handle 429 responses gracefully with exponential backoff.Third, result ordering: even without race conditions, results should match the current query. If the user types “re”, then “react”, debouncing ensures only “react” fires, but if both fire, you need the isCancelled/AbortController pattern. The combination of debouncing plus AbortController is the production-grade solution.The implementation: useDebounce hook on the query, useEffect that fetches on the debounced value, AbortController in the effect cleanup. This is so common that libraries like React Query handle all of it with a single useQuery({ queryKey: ['search', debouncedQuery], queryFn: ... }) call.