On this page

useEffect and Lifecycle: Synchronizing with the Outside World

14 min read TextCh. 2 — State and Effects

What is useEffect?

The useEffect hook lets you synchronize a component with an external system. That external system can be a network API, a browser API like localStorage, a third-party library, a WebSocket, a timer, or any side effect that does not directly produce UI.

In the class component era, this work was spread across lifecycle methods: componentDidMount, componentDidUpdate, and componentWillUnmount. useEffect unifies all three into a single, declarative API.

import { useEffect } from 'react';

function DocumentTitle({ title }: { title: string }) {
  useEffect(() => {
    document.title = title;
    // No cleanup needed here
  }, [title]);

  return null;
}

The Three Forms of useEffect

The dependency array controls when the effect runs:

1. No Dependency Array — Runs After Every Render

useEffect(() => {
  console.log('This runs after every render');
});

This form is rarely correct. It runs after every state or prop change, which usually causes infinite loops or excessive work.

2. Empty Dependency Array — Runs Once on Mount

useEffect(() => {
  console.log('This runs once when the component mounts');
  // Equivalent to componentDidMount
}, []);

Use this for one-time setup: initializing analytics, fetching initial data, connecting to a WebSocket.

3. Dependency Array — Runs When Dependencies Change

useEffect(() => {
  console.log(`userId changed to: ${userId}`);
  // Re-runs every time userId changes
}, [userId]);

This is the most common form. The effect re-runs whenever any value in the dependency array changes (by reference equality).

The Cleanup Function

Return a function from useEffect to clean up when the component unmounts or before the effect re-runs:

function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const socket = new WebSocket(`wss://chat.example.com/room/${roomId}`);

    socket.onmessage = (event) => {
      console.log('Message:', event.data);
    };

    socket.onopen = () => console.log(`Connected to room ${roomId}`);

    // Cleanup: close the connection
    return () => {
      socket.close();
      console.log(`Disconnected from room ${roomId}`);
    };
  }, [roomId]);

  return <div>Chatting in room: {roomId}</div>;
}

When roomId changes, React:

  1. Runs the cleanup for the previous effect (closes the old socket).
  2. Runs the new effect (opens a new socket for the new room).

Fetching Data with useEffect

Data fetching is the most common use case for useEffect. The correct pattern includes loading state, error handling, and request cancellation:

import { useState, useEffect } from 'react';

interface Post {
  id: number;
  title: string;
  body: string;
}

function PostList() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

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

    setLoading(true);
    setError(null);

    fetch('https://jsonplaceholder.typicode.com/posts?_limit=5', {
      signal: controller.signal,
    })
      .then((res) => {
        if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`);
        return res.json() as Promise<Post[]>;
      })
      .then(setPosts)
      .catch((err: unknown) => {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      })
      .finally(() => setLoading(false));

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

  if (loading) return <p>Loading posts...</p>;
  if (error) return <p role="alert">Error: {error}</p>;

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </li>
      ))}
    </ul>
  );
}

Note the AbortController pattern — it cancels the in-flight fetch request if the component unmounts before the response arrives, preventing state updates on unmounted components.

Synchronizing with localStorage

function usePersistentTheme() {
  const [theme, setTheme] = useState<'light' | 'dark'>(() => {
    // Lazy initial state — reads from localStorage once on mount
    const stored = localStorage.getItem('theme');
    return stored === 'dark' ? 'dark' : 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  return [theme, setTheme] as const;
}

The lazy initial state pattern (passing a function to useState) is useful when computing the initial value is expensive or reads from a side-effect source like localStorage.

Event Listeners

Always add event listeners in useEffect and remove them in the cleanup:

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

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

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

  return size;
}

Avoiding Common Mistakes

Mistake 1: Object/Array in Dependency Array

Objects and arrays are compared by reference. Creating them inline causes the effect to run on every render:

// WRONG — new object created on every render
useEffect(() => { /* ... */ }, [{ userId }]);

// CORRECT — primitive values
useEffect(() => { /* ... */ }, [userId]);

Mistake 2: Missing Dependencies

// WRONG — count is used but not listed
useEffect(() => {
  console.log(count);
}, []);

// CORRECT
useEffect(() => {
  console.log(count);
}, [count]);

Mistake 3: Setting State Without a Guard

If you set state inside an effect and that state is in the dependency array, you have an infinite loop:

// WRONG — infinite loop
const [data, setData] = useState(null);
useEffect(() => {
  setData(someValue); // triggers re-render → effect runs again → ...
}, [data]);

// CORRECT — only run when a stable value changes
useEffect(() => {
  setData(someValue);
}, [someStableProp]);

React StrictMode Double Invocation

In development with StrictMode, React intentionally mounts, unmounts, and remounts each component to help you discover effects that do not clean up properly. Effects run twice — but only in development. This means your cleanup function must properly reverse everything the setup does.

useEffect(() => {
  console.log('Effect runs');
  return () => console.log('Cleanup runs');
}, []);

// Development output:
// Effect runs
// Cleanup runs
// Effect runs (second mount)

This is not a bug — it is React helping you write correct cleanup functions.

When Not to Use useEffect

React 19 guidelines advise against using useEffect for:

  • Transforming data for rendering — compute it during render or use useMemo
  • Handling user events — put logic in event handlers, not effects
  • Resetting state when props change — use the key prop or event handlers instead
  • Fetching data — prefer TanStack Query, SWR, or React Server Components (covered in lesson 11)

useEffect should be reserved for synchronizing with truly external systems that React does not control.

In the next lesson, you will learn about useRef, useMemo, and useCallback — the optimization hooks that give you fine-grained control over component behavior.

The dependency array must be exhaustive
Every reactive value used inside `useEffect` (state, props, context, variables from the component body) must be listed in the dependency array. Missing dependencies lead to stale closure bugs. Use the `eslint-plugin-react-hooks` rule `exhaustive-deps` to catch these issues automatically.
Always clean up subscriptions and timers
Return a cleanup function from `useEffect` for any subscription, timer, event listener, or network request. React calls this function before re-running the effect and when the component unmounts. Skipping cleanup causes memory leaks and unexpected behavior.
Effects run after the browser paints
React runs effects asynchronously after the browser has painted the screen. If you need to measure DOM nodes or update the DOM before the browser paints (to avoid flickering), use `useLayoutEffect` instead. For most data fetching and subscriptions, `useEffect` is the right choice.
import { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
  company: { name: string };
}

interface UserProfileProps {
  userId: number;
}

function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // Reset state when userId changes
    setLoading(true);
    setError(null);

    // Create an AbortController for cleanup
    const controller = new AbortController();

    async function fetchUser() {
      try {
        const res = await fetch(
          `https://jsonplaceholder.typicode.com/users/${userId}`,
          { signal: controller.signal }
        );
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json() as User;
        setUser(data);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchUser();

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

  if (loading) return <p>Loading user {userId}...</p>;
  if (error) return <p role="alert">Error: {error}</p>;
  if (!user) return null;

  return (
    <article>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.company.name}</p>
    </article>
  );
}

export default UserProfile;