Week 6

useEffect - Side Effects, Data Fetching & Async State

Browser DevTools - The Network Tab

Next.js - Structure & Routing

Tailwind v4

Next.js - Deployment

Practice

Assignment

Back to Track

What is useEffect?

Your React components from the past two weeks have been pure: given the same props and state, they always return the same JSX. That's a great property for rendering, but real applications need to do things beyond rendering: fetch data from a server, set up a timer, update the page title. These are called side effects: operations that reach outside the component and interact with “the outside world” (outside of your React application).

useEffect is the hook that lets you run side effects after your component renders. The key word is after — React finishes updating the DOM first, then runs your effect. This keeps rendering fast and predictable.

Watch this video (multiple times) and you will truly be a master of useEffect

https://www.youtube.com/watch?v=dH6i3GurZW8

Before we go further: useEffect is a powerful tool, and it's easy to reach for it too quickly. Throughout this lesson you'll learn not just how to use it, but also (just as importantly) when not to.

<aside> 💡

Info

The React docs describe Effects as an "escape hatch" from the React paradigm. They let you step outside of React to talk to external systems. If there's no external system involved, there's a good chance you don't need an Effect at all. We'll come back to this.

</aside>

Your First useEffect

Here's the simplest possible example: updating the browser tab title when a component mounts:

import { useEffect } from 'react';

function ProfilePage({ username }: { username: string }) {
  useEffect(() => {
    document.title = `${username}'s Profile`;
  });

  return <h1>{username}</h1>;
}

The function you pass to useEffect runs after every render by default. That means every time ProfilePage renders. Whether from a prop change, state update, or parent re-render: the effect runs and updates the title.

This works, but running after every single render is often more than you need. That's what the dependency array is for (covered in the next section).

The three forms of useEffect

// 1. No dependency array: runs after every render
useEffect(() => {
  console.log('renders every time');
});

// 2. Empty array: runs once when the component mounts
useEffect(() => {
  console.log('runs once on mount');
}, []);

// 3. With dependencies: runs when those values change
useEffect(() => {
  console.log('runs when userId changes');
}, [userId]);

<aside> ⌨️

Hands On

Create a component that shows a counter. Use useEffect with no dependency array to log "rendered!" to the console on every render. Then add a button that increments the counter and watch the console. How many times does it log when you click three times?

</aside>

Fetching Data from a REST API

The most common use of useEffect is fetching data when a component loads. Here's a realistic example using the GitHub API (no API key required):

import { useState, useEffect } from 'react';

interface Repository {
  id: number;
  name: string;
  description: string | null;
  stargazers_count: number;
  html_url: string;
}

function GitHubRepos({ username }: { username: string }) {
  const [repos, setRepos] = useState<Repository[]>([]);

  useEffect(() => {
    fetch(`https://api.github.com/users/${username}/repos`)
      .then((response) => response.json())
      .then((data) => setRepos(data));
  }, [username]);

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

The fetch call lives inside useEffect because fetching is a side effect: it reaches out to an external system (the GitHub API). You can't put it directly in the component body, because rendering must stay pure.

Notice [username] in the dependency array. If the username prop changes (the user navigates to a different profile) the effect re-runs and fetches the new data automatically.

<aside> ⚠️

Warning

Don't do this:

// 🔴 Never put async directly on the useEffect callback
useEffect(async () => {
  const data = await fetch('...');
}, []);

The useEffect callback can return a cleanup function or nothing at all. An async function always returns a Promise, which breaks this contract. Instead, define the async function inside the effect and call it:

useEffect(() => {
  async function loadData() {
    const response = await fetch('...');
    const data = await response.json();
    setRepos(data);
  }
  loadData();
}, []);

</aside>

Handling Async State: Loading, Error, Success

The example above has a hidden problem: what does the user see while the data is loading? What if the request fails? A robust component needs to handle three distinct states.

Here's the same component rewritten with proper async state management:

import { useState, useEffect } from 'react';

interface Repository {
  id: number;
  name: string;
  description: string | null;
  stargazers_count: number;
  html_url: string;
}

type FetchState =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: Repository[] };

function GitHubRepos({ username }: { username: string }) {
  const [state, setState] = useState<FetchState>({ status: 'loading' });

  useEffect(() => {
    // Reset to loading whenever username changes
    setState({ status: 'loading' });

    fetch(`https://api.github.com/users/${username}/repos`)
      .then((response) => {
        if (!response.ok) {
          throw new Error(`HTTP error: ${response.status}`);
        }
        return response.json();
      })
      .then((data: Repository[]) => {
        setState({ status: 'success', data });
      })
      .catch((error: Error) => {
        setState({ status: 'error', message: error.message });
      });
  }, [username]);

  if (state.status === 'loading') {
    return <p>Loading repositories...</p>;
  }

  if (state.status === 'error') {
    return <p>Something went wrong: {state.message}</p>;
  }

  return (
    <ul>
      {state.data.map((repo) => (
        <li key={repo.id}>
          <a href={repo.html_url}>{repo.name}</a>
          {repo.description && <p>{repo.description}</p>}
          <span>⭐ {repo.stargazers_count}</span>
        </li>
      ))}
    </ul>
  );
}

A few things worth pointing out here:

The TypeScript union type FetchState models the three possible states explicitly. This is a pattern from Week 4: your state can only ever be in one of these three shapes at a time, making impossible states like "has both an error and data" unrepresentable.

Setting setState({ status: 'loading' }) at the top of the effect means that whenever username changes, the UI immediately shows the loading state before the new fetch completes. Without this, users would briefly see stale data from the previous username.

<aside> ⌨️

Hands On

Pick any public REST API you find interesting — the Open Library API, PokeAPI, or REST Countries all work without authentication. Build a component that fetches and displays data from it, handling loading, error, and success states. Add a try/catch around the fetch if you're using async/await.

</aside>

The Dependency Array

The dependency array is the second argument to useEffect. It tells React when to re-run your effect.

useEffect(() => {
  document.title = `${username}'s Profile`;
}, [username]); // Re-run when username changes

React compares each value in the array to what it was on the previous render. If nothing changed, the effect is skipped. If anything changed, the effect runs again.

The linting rule that has your back

If you're using a React project set up with ESLint (which Next.js gives you by default), you'll have the react-hooks/exhaustive-deps rule enabled. This rule analyses your effect code and warns you if you're using a value from the component without listing it as a dependency.

function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetch(`/api/search?q=${query}`) // query is used here...
      .then((res) => res.json())
      .then(setResults);
  }, []); // 🔴 ESLint: React Hook useEffect has a missing dependency: 'query'
}

The fix is to add query to the array:

  }, [query]); // ✅ Now the effect re-runs when query changes

Trust the linter! It's not being overly protective or anything: it's preventing a real class of bug where your effect runs with a stale value because you forgot to list a dependency. The rule is: if your effect reads a value from the component (props, state, or anything derived from them), that value belongs in the dependency array.

<aside> ⚠️

Warning

You might be tempted to silence the linter with a comment like this:

// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Resist this. When you suppress the dependency linter, you're telling React "this effect doesn't depend on anything" while the code inside it does. React will run your effect with stale values and you'll spend a long time debugging why your UI isn't updating correctly. If you genuinely want to skip re-running on certain values, the right approach is to restructure the code — not silence the warning.

</aside>

What counts as a dependency?

Props, state variables, and anything derived from them inside the component are reactive values. They change over time, so effects that use them must list them.

Constants defined outside the component are not reactive: they never change across renders, so they don't need to be listed:

const API_BASE_URL = '<https://api.example.com>'; // Outside the component — not a dependency

function UserProfile({ userId }: { userId: string }) {
  // userId is a prop — it's reactive, it belongs in the array
  useEffect(() => {
    fetch(`${API_BASE_URL}/users/${userId}`); // API_BASE_URL never changes, userId does
  }, [userId]); // ✅ Only userId needed
}

Cleanup: Preventing Memory Leaks and Stale Updates

Some effects set something up that needs to be torn down when the component unmounts or before the effect runs again. Without cleanup, you can end up with memory leaks, stale event listeners, or in the case of data fetching, stale updates where a response from an old request overwrites the result of a newer one.

The cleanup function

Return a function from your effect to clean up:

useEffect(() => {
  // Setup
  const timerId = setInterval(() => {
    setCount((c) => c + 1);
  }, 1000);

  // Cleanup — React calls this when the component unmounts
  // or before this effect runs again
  return () => {
    clearInterval(timerId);
  };
}, []);

Cancelling stale fetch requests

Here's a subtle but important bug in the earlier data fetching code: if username changes before the first fetch completes, you now have two requests in flight. Whichever resolves last will "win" and update state: even if it was the older request. This can show the wrong data.

The fix is an AbortController:

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

  setState({ status: 'loading' });

  fetch(`https://api.github.com/users/${username}/repos`, {
    signal: controller.signal, // Pass the signal to fetch
  })
    .then((response) => {
      if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
      return response.json();
    })
    .then((data: Repository[]) => {
      setState({ status: 'success', data });
    })
    .catch((error: Error) => {
      // AbortError is expected — it means the request was intentionally cancelled
      if (error.name !== 'AbortError') {
        setState({ status: 'error', message: error.message });
      }
    });

  // Cleanup: abort the request if username changes or component unmounts
  return () => {
    controller.abort();
  };
}, [username]);

When username changes, React calls the cleanup function, which aborts the in-flight request. The new effect then starts a fresh fetch for the new username. No stale data.

<aside> ⚠️

Info

You might notice during development that your effects run twice — setup, cleanup, setup — even when you didn't navigate anywhere. This is React's Strict Mode deliberately remounting components to surface cleanup bugs. It only happens in development, not in production. If your cleanup is correct, the double invocation has no visible effect on your UI.

</aside>

<aside> ⌨️

Hands On

Take the component you built in the previous hands-on exercise and add an AbortController for cleanup. To see it in action, add a button that quickly changes the search term and watch the Network tab in DevTools: you should see cancelled requests.

</aside>

Do You Actually Need useEffect?

This is the most important question to ask before writing a useEffect. Many developers, especially those new to React, reach for useEffect by reflex whenever they want to "do something when state changes." Often, a simpler approach exists.

Here are the two most common cases where you think you need useEffect but don't.

Deriving values from state or props

Say you have a user's first and last name in state and want to display their full name. You might reach for useEffect:

// 🔴 Unnecessary — don't do this
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

This is wasteful. React renders with a stale fullName first, then re-renders again after the effect runs. You've added an entire extra render cycle for no reason.

The fix is to just calculate it during render:

// ✅ Calculate during rendering — no effect needed
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

const fullName = `${firstName} ${lastName}`; // Recalculated automatically on every render

This is faster, simpler, and there's no state to get out of sync.

Responding to user events

If you want to do something when a user clicks a button, that goes in the event handler, not in an effect!

// 🔴 Awkward and unnecessary
function CheckoutButton({ cartId }: { cartId: string }) {
  const [purchased, setPurchased] = useState(false);

  useEffect(() => {
    if (purchased) {
      fetch('/api/purchase', { method: 'POST', body: JSON.stringify({ cartId }) });
    }
  }, [purchased, cartId]);

  return <button onClick={() => setPurchased(true)}>Buy</button>;
}

// ✅ Just use the event handler
function CheckoutButton({ cartId }: { cartId: string }) {
  function handlePurchase() {
    fetch('/api/purchase', { method: 'POST', body: JSON.stringify({ cartId }) });
  }

  return <button onClick={handlePurchase}>Buy</button>;
}

Event handlers know exactly what happened and when. Effects don't have that context, because by the time they run the specific user action is already gone.

The question to ask yourself

Before writing useEffect, ask: is this synchronizing with something external? A server, a browser API, a third-party library, a WebSocket — something that lives outside React.

If the answer is no, if you're just transforming data or responding to user input, there's almost certainly a simpler way.

<aside> ⚠️

</aside>

Putting It Together: A Complete Example