Skip to main content

โšก The useEffect Hook

So far, your React components have been pure functionsโ€”they take props and state, and they render JSX. But real applications need to do more than just render. They need to fetch data from APIs, subscribe to events, update the document title, set timers, and interact with the browser. These are called "side effects," and React's useEffect hook is how you handle them. Think of useEffect as your component's way of saying "after you render, do this extra thing." Let's master this essential hook! ๐Ÿš€

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Understand what side effects are and why they need special handling
  • Use the useEffect hook to perform side effects
  • Master dependency arrays and when effects run
  • Implement cleanup functions to prevent memory leaks
  • Understand effect execution timing
  • Handle common effect patterns (timers, subscriptions, document updates)
  • Debug effects and avoid infinite loops
  • Type effects properly with TypeScript
  • Know when to use useEffect vs other solutions

Estimated Time: 70-85 minutes

Project: Build a timer, document title updater, and event listener examples

In This Lesson

๐ŸŽญ What Are Side Effects?

Before we reach for useEffect, we need to understand what a "side effect" actually isโ€”and why React insists on handling them separately from rendering.

Here's a helpful analogy. Think of a React component as a recipe. When React "renders," it reads your recipe and describes the finished dish (the JSX)โ€”but it doesn't touch the kitchen. A side effect is the moment you actually do something in the real world: turn on the oven, ring the timer, phone the supplier for more flour. Rendering should be pure description; side effects are the real-world actions that happen after the description is ready. ๐Ÿณ

๐Ÿ“– Definition

Side Effect: Any operation that reaches outside the component's own render calculationโ€”touching something in the "outside world." In React terms, that's anything beyond taking props and state and returning JSX. Fetching from an API, starting a timer, subscribing to an event, writing to localStorage, or setting document.title are all side effects.

Pure Functions vs Side Effects

Understanding Purity

// โœ… PURE Function (no side effects)
function add(a: number, b: number): number {
    return a + b; // Only returns a value, no external changes
}

// โœ… PURE React Component
const Greeting: React.FC<{ name: string }> = ({ name }) => {
    return <h1>Hello, {name}!</h1>; // Only renders JSX
};

// โŒ IMPURE (has side effects)
function addAndLog(a: number, b: number): number {
    console.log('Adding numbers'); // Side effect: logging
    return a + b;
}

// โŒ IMPURE React Component (DON'T DO THIS)
const BadComponent: React.FC = () => {
    document.title = 'My App'; // Side effect in render!
    fetch('/api/data'); // Side effect in render!
    return <h1>Hello</h1>;
};

Common Side Effects in React

What Counts as a Side Effect?

Side Effect Type Examples Why It's a Side Effect
Data Fetching fetch(), axios.get() Network request affects external world
Subscriptions addEventListener, WebSocket Registers external listeners
Timers setTimeout, setInterval Schedules future code execution
DOM Manipulation document.title, focus() Directly modifies the DOM
Logging console.log() Outputs to console (external)
Browser APIs localStorage, navigator Interacts with browser APIs
External Libraries Google Analytics, charts Calls external code

Why Side Effects Need Special Handling

โš ๏ธ The Problem with Side Effects During Render

// โŒ BAD: Side effect during render
const BadTimer: React.FC = () => {
    const [count, setCount] = useState(0);
    
    // Problem: This runs on EVERY render!
    setInterval(() => {
        setCount(prev => prev + 1);
    }, 1000);
    // Result: Hundreds of timers created, app crashes!
    
    return <div>Count: {count}</div>;
};

// โŒ BAD: Fetch during render
const BadFetch: React.FC = () => {
    const [data, setData] = useState(null);
    
    // Problem: Fetches on every render, infinite loop!
    fetch('/api/data')
        .then(res => res.json())
        .then(setData); // Setting state triggers re-render!
    
    return <div>{data}</div>;
};

React's Rendering Model

graph TD A["๐Ÿ”„ Component Function Called"] --> B["๐Ÿ“ Calculate JSX"] B --> C["๐Ÿ“ค Return JSX"] C --> D["๐ŸŽจ React Updates DOM"] D --> E["๐Ÿ–ฅ๏ธ Browser Paints Screen"] E --> F["๐Ÿ‘๏ธ User Sees Update"] G["โšก Side Effects"] -.->|"Should happen AFTER"| D style A fill:#667eea,stroke:#5a67d8,color:#fff style G fill:#f44336,stroke:#d32f2f,color:#fff style D fill:#4CAF50,stroke:#388e3c,color:#fff

๐ŸŽฎ Interactive: Pure vs Impure Functions

Click each call below. A pure function is safe to run during renderโ€”same input, same output, no ripples in the outside world. An impure function reaches out and changes something, so it belongs in an effect, not in render.

add(a, b) // pure ยท addAndLog(a, b) // impure (logs + mutates a counter)

๐Ÿ‘† Click an example to see whether it is pure (render-safe) or a side effect.

The key insight: render functions should be pure. Side effects should happen after rendering completesโ€”and useEffect is React's official doorway from pure rendering into the messy, wonderful outside world.

๐ŸŽฃ Introduction to useEffect

The useEffect hook tells React "after you finish rendering this component, run this code." It's your escape hatch from pure rendering into the world of side effects. The name is worth pausing on: an effect is meant to synchronize your component with an external systemโ€”the browser DOM, a network connection, a timer, a third-party widget. If there's no external system involved, you probably don't need an effect at all.

๐Ÿค” First, ask: do I even need an Effect?

This is the single most important mindset shift in modern React (2026). The official React docs have a whole page titled "You Might Not Need an Effect," and it's the first thing experienced developers reach for. Before writing useEffect, check:

  • Transforming data for rendering? Just calculate it during render. const total = items.reduce(...)โ€”no effect, no extra state.
  • Responding to a user action? Put that logic in the event handler (the onClick), not in an effect that watches state.
  • Genuinely syncing with something outside React? (a subscription, the document title, a timer) That's when an effect earns its place.

We'll come back to this in Best Practices. Keep it in the back of your mind for the whole lesson: an effect is a tool for synchronization, not a dumping ground for "code that runs later."

The Concept

useEffect(() => {
    // This code runs AFTER the component renders
    console.log('Component rendered!');
    
    // Perform side effects here:
    // - Fetch data
    // - Set up subscriptions
    // - Update document
    // - Start timers
});

When Does useEffect Run?

๐Ÿ’ก Effect Timing

graph LR A[Component Mounts] --> B[Render JSX] B --> C[React Updates DOM] C --> D[Browser Paints] D --> E[useEffect Runs] F[State Changes] --> G[Re-render] G --> H[React Updates DOM] H --> I[Browser Paints] I --> J[useEffect Runs Again] style E fill:#4CAF50,color:#fff style J fill:#4CAF50,color:#fff

Your First useEffect

Simple Example: Document Title

import { useState, useEffect } from 'react';

const PageTitle: React.FC = () => {
    const [count, setCount] = useState(0);
    
    // โœ… Effect runs after every render
    useEffect(() => {
        document.title = `Count: ${count}`;
        console.log('Effect ran!');
    });
    
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(count + 1)}>
                Increment
            </button>
        </div>
    );
};

// What happens:
// 1. Component renders with count = 0
// 2. Effect runs, sets title to "Count: 0"
// 3. User clicks button
// 4. Component re-renders with count = 1
// 5. Effect runs again, sets title to "Count: 1"

Effect Flow Diagram

sequenceDiagram participant U as User participant C as Component participant R as React participant E as useEffect participant B as Browser U->>C: Open page C->>R: Render JSX with count 0 R->>B: Update DOM B->>B: Paint screen B->>E: Trigger effect E->>B: Set document.title U->>C: Click button C->>C: Call setCount to 1 C->>R: Re-render JSX with count 1 R->>B: Update DOM B->>B: Paint screen B->>E: Trigger effect again E->>B: Set document.title again

Importing useEffect

Where It Comes From

// Import from React
import { useEffect } from 'react';

// Or with other hooks
import { useState, useEffect } from 'react';

// It's a hook, so:
// โœ… Call it at the top level of your component
// โŒ Don't call it inside loops, conditions, or nested functions

๐Ÿ“ Basic useEffect Syntax

Let's break down the anatomy of useEffect and understand each part.

The Complete Syntax

Full Form

useEffect(
    // 1. Effect function (required)
    () => {
        // Side effect code goes here
        console.log('Effect running');
        
        // 2. Cleanup function (optional)
        return () => {
            console.log('Cleanup running');
        };
    },
    // 3. Dependency array (optional but important!)
    [dependency1, dependency2]
);

Part 1: The Effect Function

What Goes Inside?

useEffect(() => {
    // This is the effect function
    // Put your side effects here
    
    // Examples:
    console.log('Component rendered');
    document.title = 'My App';
    fetch('/api/data');
    const timer = setTimeout(() => {}, 1000);
    
    // Any code that should run after render
});

Effect Function Examples

// Example 1: Update document title
useEffect(() => {
    document.title = `You clicked ${count} times`;
});

// Example 2: Log when component renders
useEffect(() => {
    console.log('Component rendered with props:', props);
});

// Example 3: Focus an input
useEffect(() => {
    inputRef.current?.focus();
});

// Example 4: Set up analytics
useEffect(() => {
    analytics.track('Page viewed', { page: 'Home' });
});

Part 2: The Cleanup Function (Optional)

Returning a Cleanup Function

useEffect(() => {
    // Set up
    const timer = setInterval(() => {
        console.log('Tick');
    }, 1000);
    
    // Cleanup (optional but often needed)
    return () => {
        clearInterval(timer);
        console.log('Timer cleaned up');
    };
});

// When does cleanup run?
// 1. Before the effect runs again (on re-render)
// 2. When the component unmounts (is removed)

โœ… Why Cleanup Matters

// โŒ WITHOUT cleanup: Memory leak!
useEffect(() => {
    const timer = setInterval(() => {
        console.log('Tick');
    }, 1000);
    // Timer keeps running even after component unmounts!
});

// โœ… WITH cleanup: Proper resource management
useEffect(() => {
    const timer = setInterval(() => {
        console.log('Tick');
    }, 1000);
    
    return () => {
        clearInterval(timer); // Stop timer when done
    };
});

Part 3: The Dependency Array (Critical!)

Three Ways to Use Dependencies

// 1. No dependency array: Runs after EVERY render
useEffect(() => {
    console.log('Runs on every render');
}); // โš ๏ธ Usually not what you want!

// 2. Empty dependency array: Runs ONCE on mount
useEffect(() => {
    console.log('Runs once when component mounts');
}, []); // โœ… Common pattern

// 3. With dependencies: Runs when dependencies change
useEffect(() => {
    console.log('Runs when count changes');
}, [count]); // โœ… Most common pattern

Visualizing Dependency Behavior

flowchart TD A["๐Ÿ”„ Component renders"] --> B{"Dependency array?"} B -->|"No array"| C["๐Ÿ” Run effect on EVERY render"] B -->|"Empty []"| D{"Is this the first render?"} D -->|"Yes"| E["โœ… Run effect"] D -->|"No"| F["โญ๏ธ Skip effect"] B -->|"With deps [x, y]"| G{"Did any dependency change?"} G -->|"Yes"| H["โœ… Run effect"] G -->|"No"| I["โญ๏ธ Skip effect"] style C fill:#f44336,stroke:#d32f2f,color:#fff style E fill:#4caf50,stroke:#388e3c,color:#fff style H fill:#4caf50,stroke:#388e3c,color:#fff style F fill:#9e9e9e,stroke:#757575,color:#fff style I fill:#9e9e9e,stroke:#757575,color:#fff

๐ŸŽฎ Interactive: When Does the Effect Run?

The second argument to useEffectโ€”the dependency arrayโ€”decides when your effect fires. Click each configuration to see React's rule in plain English. Green means "this is a healthy, intentional pattern"; red means "this usually signals a bug or wasted work."

useEffect(() => { /* effect */ }, deps)

๐Ÿ‘† Pick a dependency configuration to see when React will run the effect.

TypeScript with useEffect

Typing Effects

import { useEffect } from 'react';

// Effect function doesn't return anything (except cleanup)
useEffect(() => {
    // TypeScript infers this returns void
    console.log('Hello');
});

// Cleanup function must return void
useEffect(() => {
    const timer = setInterval(() => {}, 1000);
    
    // โœ… Correct: returns void
    return () => {
        clearInterval(timer);
    };
    
    // โŒ Wrong: can't return other values
    // return 42; // TypeScript error!
});

// Dependencies must be array
useEffect(() => {
    console.log(count);
}, [count]); // โœ… Array of dependencies

// Common TypeScript pattern
interface Props {
    userId: string;
}

const UserProfile: React.FC<Props> = ({ userId }) => {
    useEffect(() => {
        // userId is properly typed as string
        console.log(`Fetching data for user: ${userId}`);
    }, [userId]); // TypeScript checks userId is in scope
    
    return <div>User Profile</div>;
};

๐ŸŽฏ Dependency Arrays Explained

The dependency array is the most important and confusing part of useEffect. Let's master it completely!

How Dependencies Work

The Rule

React compares each dependency with its previous value. If any dependency changed, the effect runs.

const [count, setCount] = useState(0);
const [name, setName] = useState('Alice');

useEffect(() => {
    console.log(`Count: ${count}, Name: ${name}`);
}, [count, name]);

// Scenario 1: count changes from 0 to 1
// Result: Effect runs โœ…

// Scenario 2: name changes from 'Alice' to 'Bob'
// Result: Effect runs โœ…

// Scenario 3: Neither changes
// Result: Effect doesn't run โŒ

// Scenario 4: Both change
// Result: Effect runs once โœ… (not twice!)

Three Dependency Patterns

Pattern 1: No Dependency Array

// Runs after EVERY render
useEffect(() => {
    console.log('Every render');
}); // No second argument

// Use case: Rarely needed
// Usually indicates you should use a dependency array instead

Pattern 2: Empty Dependency Array

// Runs ONCE when component mounts
useEffect(() => {
    console.log('Component mounted');
    
    return () => {
        console.log('Component unmounted');
    };
}, []); // Empty array

// Use cases:
// - Initialize data on mount
// - Set up subscriptions
// - Start timers
// - Fetch initial data

// Example: Fetch data once
useEffect(() => {
    fetch('/api/user')
        .then(res => res.json())
        .then(data => setUser(data));
}, []); // Runs once on mount

Pattern 3: With Dependencies

// Runs when specified values change
useEffect(() => {
    console.log(`User ${userId} selected`);
    fetchUserData(userId);
}, [userId]); // Runs when userId changes

// Use cases:
// - Sync with prop changes
// - React to state changes
// - Re-fetch when parameters change

// Example: Search as user types
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);

useEffect(() => {
    if (searchTerm) {
        fetch(`/api/search?q=${searchTerm}`)
            .then(res => res.json())
            .then(data => setResults(data));
    }
}, [searchTerm]); // Re-run when search term changes

What Should Go in Dependencies?

โœ… The Golden Rule

Every value from the component scope that's used inside the effect MUST be in the dependency array.

const [count, setCount] = useState(0);
const [multiplier, setMultiplier] = useState(2);

// โœ… CORRECT: Both count and multiplier are dependencies
useEffect(() => {
    const result = count * multiplier;
    console.log(`Result: ${result}`);
}, [count, multiplier]);

// โŒ WRONG: Missing multiplier from dependencies
useEffect(() => {
    const result = count * multiplier; // Uses multiplier
    console.log(`Result: ${result}`);
}, [count]); // But doesn't list it!
// This will use stale values of multiplier!

Common Dependency Mistakes

โš ๏ธ Watch Out For These

// โŒ Mistake 1: Forgetting dependencies
const [userId, setUserId] = useState(1);

useEffect(() => {
    fetchUser(userId); // Uses userId
}, []); // But empty array! Will always fetch user 1

// โœ… Fix: Include userId
useEffect(() => {
    fetchUser(userId);
}, [userId]);

// โŒ Mistake 2: Including functions that change every render
const handleClick = () => {
    console.log('Clicked');
};

useEffect(() => {
    document.addEventListener('click', handleClick);
}, [handleClick]); // handleClick is new every render!

// โœ… Fix: Define function inside effect
useEffect(() => {
    const handleClick = () => {
        console.log('Clicked');
    };
    document.addEventListener('click', handleClick);
}, []);

// โŒ Mistake 3: Object dependencies
const config = { theme: 'dark', lang: 'en' };

useEffect(() => {
    applyConfig(config);
}, [config]); // config is new object every render!

// โœ… Fix: Depend on individual properties
useEffect(() => {
    applyConfig({ theme: 'dark', lang: 'en' });
}, []); // Or use theme and lang as separate dependencies

Reference vs Value Dependencies

Understanding Comparison

// Primitives: Compared by value
const [count, setCount] = useState(0);
useEffect(() => {
    console.log(count);
}, [count]); // โœ… count compared by value (0 === 0)

// Objects: Compared by reference
const [user, setUser] = useState({ name: 'Alice' });
useEffect(() => {
    console.log(user.name);
}, [user]); // โš ๏ธ user compared by reference (may cause extra runs)

// Better: Depend on specific properties
useEffect(() => {
    console.log(user.name);
}, [user.name]); // โœ… Compare only name property

// Arrays: Also compared by reference
const [items, setItems] = useState([1, 2, 3]);
useEffect(() => {
    console.log('Items changed');
}, [items]); // New array reference triggers effect

// Functions: Always new reference
const handleSubmit = () => {
    console.log('Submit');
};
useEffect(() => {
    // ...
}, [handleSubmit]); // โš ๏ธ Different function every render!

ESLint Rule: exhaustive-deps

๐Ÿ’ก Let ESLint Help You

You don't have to police the dependency array by hand. React ships an ESLint rule, react-hooks/exhaustive-deps, that reads your effect, works out exactly which values it uses, and warns you the instant the array is out of sync. Treat it as a helpful pair-programmer, not a nag.

# Install if not already installed
npm install --save-dev eslint-plugin-react-hooks

Modern ESLint (v9+) uses a flat config file, eslint.config.js. In 2026 the simplest setup is the plugin's recommended preset, which turns the rule on for you:

// eslint.config.js (flat config)
import reactHooks from 'eslint-plugin-react-hooks';

export default [
  // Enables react-hooks/rules-of-hooks (error)
  // and react-hooks/exhaustive-deps (warn)
  reactHooks.configs['recommended-latest'],
];

Once it's on, the rule warns you when:

  • You're missing a dependency (the classic source of stale values)
  • You have an unnecessary dependency
  • Your effect might cause an infinite loop

Rule of thumb: if the linter wants a dependency you think you can safely omit, that's a signal to restructure the effectโ€”not to silence the warning. Suppressing exhaustive-deps is how stale-closure bugs sneak in.

๐Ÿงน Cleanup Functions

Cleanup functions are essential for preventing memory leaks and ensuring your effects don't cause problems when components unmount or re-render.

Why Cleanup Is Needed

The Problem

// โŒ WITHOUT cleanup
const Timer: React.FC = () => {
    const [seconds, setSeconds] = useState(0);
    
    useEffect(() => {
        setInterval(() => {
            setSeconds(prev => prev + 1);
        }, 1000);
    }, []);
    
    return <div>Seconds: {seconds}</div>;
};

// Problem: When component unmounts, interval keeps running!
// setSeconds tries to update non-existent component
// Memory leak! ๐Ÿ’ฅ

โœ… WITH Cleanup

const Timer: React.FC = () => {
    const [seconds, setSeconds] = useState(0);
    
    useEffect(() => {
        const intervalId = setInterval(() => {
            setSeconds(prev => prev + 1);
        }, 1000);
        
        // Cleanup function
        return () => {
            clearInterval(intervalId);
            console.log('Timer cleaned up');
        };
    }, []);
    
    return <div>Seconds: {seconds}</div>;
};

// Now: When component unmounts, interval is cleared โœ…
// No memory leak! ๐ŸŽ‰

When Cleanup Runs

sequenceDiagram participant C as Component participant E as Effect participant Cl as Cleanup Note over C,Cl: First Mount C->>E: Run effect Note over C,Cl: Dependency Changes E->>Cl: Run cleanup first Cl->>E: Then run effect again Note over C,Cl: Component Unmounts E->>Cl: Run cleanup Cl->>C: Component removed
The Effect Lifecycle: mount, update, unmount A timeline showing that on mount the effect runs; on each dependency change the cleanup runs before the effect runs again; and on unmount the final cleanup runs. Mount effect runs Update cleanup, then effect runs again (repeats each dep change) Unmount final cleanup runs Every effect that runs will have its cleanup run exactly once before the next run โ€” or on unmount.
Figure: The full lifecycle. Cleanup always pairs with the effect that set things upโ€”React never leaves a setup un-cleaned.

๐ŸŽฎ Interactive: Effect & Cleanup Lifecycle

Click through a component's life. Watch how React pairs every effect with a cleanup: it never runs the effect twice in a row without cleaning up in between.

useEffect(() => { setup(); return () => teardown(); }, [dep])

๐Ÿ‘† Step through Mount โ†’ Update โ†’ Unmount to see when the effect and its cleanup fire.

๐Ÿ” StrictMode runs your effect twice in development โ€” on purpose

When you first run a React 18 or 19 app in development with <StrictMode> on (the default in every create-vite / Next.js starter), you'll notice each effect runs twice on mount: React deliberately does mount โ†’ cleanup โ†’ mount. This is not a bug and it does not happen in production.

Why? It's a stress test for your cleanup. If your effect sets something up but forgets to tear it down, the double-invoke makes the bug obvious immediately (two sockets, two listeners, a doubled log) instead of in front of a user weeks later.

The fix is never to "make it run once." The fix is to write a correct cleanup so that setup-then-cleanup-then-setup lands you in exactly the right state. Get cleanup right and StrictMode's double-invoke becomes invisible.

Cleanup Timing Example

const EffectLifecycle: React.FC = () => {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        console.log(`Effect running for count: ${count}`);
        
        return () => {
            console.log(`Cleanup running for count: ${count}`);
        };
    }, [count]);
    
    return (
        <button onClick={() => setCount(count + 1)}>
            Count: {count}
        </button>
    );
};

// Console output:
// Mount: "Effect running for count: 0"
// Click: "Cleanup running for count: 0"
//        "Effect running for count: 1"
// Click: "Cleanup running for count: 1"
//        "Effect running for count: 2"
// Unmount: "Cleanup running for count: 2"

Common Cleanup Scenarios

1. Timers

// setTimeout
useEffect(() => {
    const timeoutId = setTimeout(() => {
        console.log('Delayed action');
    }, 3000);
    
    return () => {
        clearTimeout(timeoutId);
    };
}, []);

// setInterval
useEffect(() => {
    const intervalId = setInterval(() => {
        console.log('Repeating action');
    }, 1000);
    
    return () => {
        clearInterval(intervalId);
    };
}, []);

2. Event Listeners

useEffect(() => {
    const handleResize = () => {
        console.log('Window resized');
    };
    
    window.addEventListener('resize', handleResize);
    
    return () => {
        window.removeEventListener('resize', handleResize);
    };
}, []);

// Mouse events
useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
        console.log(`Mouse: ${e.clientX}, ${e.clientY}`);
    };
    
    document.addEventListener('mousemove', handleMouseMove);
    
    return () => {
        document.removeEventListener('mousemove', handleMouseMove);
    };
}, []);

3. Subscriptions

// WebSocket
useEffect(() => {
    const ws = new WebSocket('ws://localhost:8080');
    
    ws.onmessage = (event) => {
        console.log('Message:', event.data);
    };
    
    return () => {
        ws.close();
        console.log('WebSocket closed');
    };
}, []);

// EventSource (Server-Sent Events)
useEffect(() => {
    const eventSource = new EventSource('/api/events');
    
    eventSource.onmessage = (event) => {
        console.log('Event:', event.data);
    };
    
    return () => {
        eventSource.close();
    };
}, []);

4. Async Operations (avoiding race conditions)

Fetching inside an effect has a subtle trap. If userId changes from 1 to 2 quickly, you fire two requestsโ€”and the response for user 1 might arrive after user 2's, leaving the wrong data on screen. The cleanup function is how you cancel the stale request. There are two idiomatic patterns:

// Pattern A: a "cancelled" flag โ€” ignore a late response
useEffect(() => {
    let cancelled = false;
    
    const fetchData = async () => {
        const response = await fetch('/api/data');
        const data = await response.json();
        
        // Only update if not cancelled
        if (!cancelled) {
            setData(data);
        }
    };
    
    fetchData();
    
    return () => {
        cancelled = true; // Mark as cancelled
    };
}, []);

// Pattern B: AbortController โ€” actually cancel the request (preferred)
useEffect(() => {
    const controller = new AbortController();

    fetch('/api/data', { signal: controller.signal })
        .then(res => res.json())
        .then(data => setData(data))
        .catch(err => {
            // Aborting rejects the promise โ€” ignore that specific error
            if (err.name === 'AbortError') return;
            console.error(err);
        });

    return () => {
        controller.abort(); // Cancel the in-flight request on cleanup
    };
}, []);

๐Ÿ“ฆ For real data fetching, reach for a library (not raw useEffect)

Everything above is worth understandingโ€”but here's the honest 2026 guidance: you usually should not hand-roll data fetching with useEffect in a production app. The manual version has to juggle loading state, error state, race conditions, caching, refetching, and cleanup, and it's easy to get one of them wrong.

Instead, prefer a purpose-built tool:

  • TanStack Query (React Query) โ€” handles caching, deduping, background refetch, retries, and loading/error states for you. This is the community default for client-side fetching.
  • A framework's data layer โ€” if you're on Next.js or Remix, fetch in a Server Component or route loader so the data is ready before the component renders. No effect needed at all.

So why learn the useEffect + AbortController pattern? Because these libraries are built on top of these exact ideas, and you'll still write raw effects for the non-fetch side effectsโ€”subscriptions, timers, and DOM workโ€”that are what useEffect is genuinely best at. We go deep on TanStack Query later in the course.

โœ… Cleanup Checklist

Always clean up these resources:

  • โœ… Timers (setTimeout, setInterval)
  • โœ… Event listeners (addEventListener)
  • โœ… WebSockets and SSE connections
  • โœ… Subscriptions (RxJS, etc.)
  • โœ… Animation frames (requestAnimationFrame)
  • โœ… Async operations (fetch with AbortController)
  • โœ… Third-party library instances

โฑ๏ธ Effect Execution Timing

Understanding exactly when effects run is crucial for avoiding bugs and writing correct code.

The Render-Effect Cycle

graph TD A["๐Ÿ”„ Component Function Runs"] --> B["๐Ÿ“ Calculate JSX"] B --> C["๐Ÿ“ค Return JSX to React"] C --> D["๐Ÿ” React Compares with Previous"] D --> E["๐ŸŽจ React Updates Real DOM"] E --> F["๐Ÿ–ผ๏ธ Browser Paints Screen"] F --> G["โšก useEffect Runs"] G --> H{"Dependencies Changed?"} H -->|"Yes"| I["โ–ถ๏ธ Run Effect Again"] H -->|"No"| J["โญ๏ธ Skip Effect"] style A fill:#667eea,stroke:#5a67d8,color:#fff style G fill:#4CAF50,stroke:#388e3c,color:#fff style I fill:#FF9800,stroke:#f57c00,color:#fff style J fill:#9e9e9e,stroke:#757575,color:#fff

The Order of Operations, Step by Step

Here's the exact sequence every render follows. Steps 1โ€“5 are synchronous: React runs your component, diffs the result, and mutates the DOM in one uninterrupted burst. Then the browser paints the pixels the user actually seesโ€”and only after that does your effect run, asynchronously. That's the whole reason effects never block the screen from updating.

The React render cycle: synchronous work, then paint, then the effect Seven stacked steps. Steps 1 to 5 (run component, calculate JSX, diff, update DOM) are grouped as synchronous. Then the browser paints. Step 7, useEffect, runs asynchronously after paint. SYNCHRONOUS ASYNC 1. Component function runs 2. Calculate JSX 3. React diffs new vs previous tree 4. React mutates the real DOM 5. Browser paints โ€” the user sees the update 6. useEffect runs (after paint) screen is already updatedโ€ฆ
Figure: The render-to-paint work is synchronous; your effect runs afterward, so it never delays what the user sees. (Need to run before paintโ€”say, to measure the DOM and avoid a flicker? That's the one job for useLayoutEffect, covered next.)

useEffect vs useLayoutEffect

Two Types of Effects

Aspect useEffect useLayoutEffect
When It Runs After browser paint Before browser paint
Blocks Painting? No (asynchronous) Yes (synchronous)
Use Case Most side effects DOM measurements, preventing flicker
Performance Better (non-blocking) Can cause jank if slow
Default Choice โœ… Yes โŒ Only when needed

useEffect Timing (Standard)

const TimingExample: React.FC = () => {
    const [count, setCount] = useState(0);
    
    console.log('1. Component rendering, count:', count);
    
    useEffect(() => {
        console.log('3. Effect running, count:', count);
    });
    
    console.log('2. About to return JSX');
    
    return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

// Console output when component mounts:
// 1. Component rendering, count: 0
// 2. About to return JSX
// 3. Effect running, count: 0

// User clicks button:
// 1. Component rendering, count: 1
// 2. About to return JSX
// 3. Effect running, count: 1

Multiple Effects Execution Order

When You Have Multiple Effects

const MultipleEffects: React.FC = () => {
    useEffect(() => {
        console.log('Effect 1');
        return () => console.log('Cleanup 1');
    }, []);
    
    useEffect(() => {
        console.log('Effect 2');
        return () => console.log('Cleanup 2');
    }, []);
    
    useEffect(() => {
        console.log('Effect 3');
        return () => console.log('Cleanup 3');
    }, []);
    
    return <div>Hello</div>;
};

// On Mount:
// Effect 1
// Effect 2
// Effect 3

// On Unmount:
// Cleanup 1
// Cleanup 2
// Cleanup 3

// Effects run in the order they're defined!

Effects and State Updates

Setting State Inside Effects

const EffectWithState: React.FC = () => {
    const [count, setCount] = useState(0);
    const [doubled, setDoubled] = useState(0);
    
    // Effect that updates state
    useEffect(() => {
        console.log('Effect: Doubling count');
        setDoubled(count * 2);
    }, [count]);
    
    return (
        <div>
            <p>Count: {count}</p>
            <p>Doubled: {doubled}</p>
            <button onClick={() => setCount(count + 1)}>Increment</button>
        </div>
    );
};

// Timeline when user clicks:
// 1. setCount(1) called
// 2. Component re-renders with count = 1
// 3. Effect runs, sees count changed
// 4. setDoubled(2) called
// 5. Component re-renders again with doubled = 2

โš ๏ธ Be Careful: Effects Can Trigger More Renders

// โŒ This causes infinite loop!
const InfiniteLoop: React.FC = () => {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        setCount(count + 1); // Updates state
    }); // No dependency array!
    // Effect runs after every render
    // setCount causes render
    // Render causes effect
    // Effect causes render... ๐Ÿ’ฅ
    
    return <div>{count}</div>;
};

// โœ… Fix: Add proper dependencies
useEffect(() => {
    // Only run once on mount
}, []);

Timing with Async Effects

Effects Can't Be Async Functions

// โŒ WRONG: Can't make effect function async
useEffect(async () => {
    const data = await fetch('/api/data');
    // TypeScript error: Effect must return cleanup or void
}, []);

// โœ… CORRECT: Define async function inside
useEffect(() => {
    const fetchData = async () => {
        const response = await fetch('/api/data');
        const data = await response.json();
        setData(data);
    };
    
    fetchData();
}, []);

// โœ… CORRECT: Use .then()
useEffect(() => {
    fetch('/api/data')
        .then(res => res.json())
        .then(data => setData(data))
        .catch(err => console.error(err));
}, []);

๐ŸŽจ Common Effect Patterns

Let's explore the most common patterns you'll use with useEffect in real applications.

Pattern 1: Document Title Updates

Updating the Browser Tab Title

const DocumentTitle: React.FC = () => {
    const [count, setCount] = useState(0);
    const [unread, setUnread] = useState(0);
    
    useEffect(() => {
        if (unread > 0) {
            document.title = `(${unread}) You have notifications`;
        } else {
            document.title = 'My App';
        }
    }, [unread]);
    
    return (
        <div>
            <h1>Notifications</h1>
            <p>Unread: {unread}</p>
            <button onClick={() => setUnread(unread + 1)}>
                New Notification
            </button>
        </div>
    );
};

Pattern 2: Timers and Intervals

Countdown Timer

const Countdown: React.FC<{ seconds: number }> = ({ seconds: initialSeconds }) => {
    const [seconds, setSeconds] = useState(initialSeconds);
    const [isActive, setIsActive] = useState(false);
    
    useEffect(() => {
        if (!isActive) return;
        
        if (seconds === 0) {
            setIsActive(false);
            alert('Time is up!');
            return;
        }
        
        const intervalId = setInterval(() => {
            setSeconds(prev => prev - 1);
        }, 1000);
        
        return () => clearInterval(intervalId);
    }, [seconds, isActive]);
    
    return (
        <div>
            <h2>{seconds} seconds</h2>
            <button onClick={() => setIsActive(!isActive)}>
                {isActive ? 'Pause' : 'Start'}
            </button>
            <button onClick={() => setSeconds(initialSeconds)}>
                Reset
            </button>
        </div>
    );
};

Stopwatch

const Stopwatch: React.FC = () => {
    const [time, setTime] = useState(0);
    const [isRunning, setIsRunning] = useState(false);
    
    useEffect(() => {
        if (!isRunning) return;
        
        const startTime = Date.now() - time;
        
        const intervalId = setInterval(() => {
            setTime(Date.now() - startTime);
        }, 10);
        
        return () => clearInterval(intervalId);
    }, [isRunning]);
    
    const formatTime = (ms: number) => {
        const seconds = Math.floor(ms / 1000);
        const minutes = Math.floor(seconds / 60);
        const hours = Math.floor(minutes / 60);
        
        return `${hours.toString().padStart(2, '0')}:${(minutes % 60)
            .toString()
            .padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`;
    };
    
    return (
        <div>
            <h1>{formatTime(time)}</h1>
            <button onClick={() => setIsRunning(!isRunning)}>
                {isRunning ? 'Pause' : 'Start'}
            </button>
            <button onClick={() => { setTime(0); setIsRunning(false); }}>
                Reset
            </button>
        </div>
    );
};

Pattern 3: Event Listeners

Window Resize Listener

const WindowSize: React.FC = () => {
    const [windowSize, setWindowSize] = useState({
        width: window.innerWidth,
        height: window.innerHeight
    });
    
    useEffect(() => {
        const handleResize = () => {
            setWindowSize({
                width: window.innerWidth,
                height: window.innerHeight
            });
        };
        
        window.addEventListener('resize', handleResize);
        
        return () => {
            window.removeEventListener('resize', handleResize);
        };
    }, []);
    
    return (
        <div>
            <h2>Window Size</h2>
            <p>Width: {windowSize.width}px</p>
            <p>Height: {windowSize.height}px</p>
        </div>
    );
};

Keyboard Shortcuts

const KeyboardShortcuts: React.FC = () => {
    const [lastKey, setLastKey] = useState('');
    
    useEffect(() => {
        const handleKeyDown = (e: KeyboardEvent) => {
            // Ctrl+S to save
            if (e.ctrlKey && e.key === 's') {
                e.preventDefault();
                console.log('Save triggered');
                setLastKey('Ctrl+S');
            }
            
            // Escape to close
            if (e.key === 'Escape') {
                console.log('Close triggered');
                setLastKey('Escape');
            }
        };
        
        document.addEventListener('keydown', handleKeyDown);
        
        return () => {
            document.removeEventListener('keydown', handleKeyDown);
        };
    }, []);
    
    return (
        <div>
            <h2>Try Keyboard Shortcuts</h2>
            <p>Press Ctrl+S or Escape</p>
            <p>Last key: {lastKey}</p>
        </div>
    );
};

Pattern 4: Local Storage Sync

Persist State to localStorage

const PersistentCounter: React.FC = () => {
    const [count, setCount] = useState(() => {
        // Load from localStorage on mount
        const saved = localStorage.getItem('count');
        return saved ? parseInt(saved, 10) : 0;
    });
    
    // Save to localStorage whenever count changes
    useEffect(() => {
        localStorage.setItem('count', count.toString());
    }, [count]);
    
    return (
        <div>
            <h2>Count: {count}</h2>
            <button onClick={() => setCount(count + 1)}>Increment</button>
            <p>Refresh the page - count persists!</p>
        </div>
    );
};

Pattern 5: Focus Management

Auto-focus Input on Mount

import { useRef, useEffect } from 'react';

const AutoFocusInput: React.FC = () => {
    const inputRef = useRef<HTMLInputElement>(null);
    
    useEffect(() => {
        // Focus input when component mounts
        inputRef.current?.focus();
    }, []);
    
    return (
        <div>
            <label htmlFor="search">Search:</label>
            <input
                ref={inputRef}
                type="text"
                id="search"
                placeholder="Start typing..."
            />
        </div>
    );
};

Pattern 6: Click Outside Detection

Detect Clicks Outside Element

const DropdownMenu: React.FC = () => {
    const [isOpen, setIsOpen] = useState(false);
    const dropdownRef = useRef<HTMLDivElement>(null);
    
    useEffect(() => {
        if (!isOpen) return;
        
        const handleClickOutside = (event: MouseEvent) => {
            if (dropdownRef.current && 
                !dropdownRef.current.contains(event.target as Node)) {
                setIsOpen(false);
            }
        };
        
        document.addEventListener('mousedown', handleClickOutside);
        
        return () => {
            document.removeEventListener('mousedown', handleClickOutside);
        };
    }, [isOpen]);
    
    return (
        <div ref={dropdownRef}>
            <button onClick={() => setIsOpen(!isOpen)}>
                Toggle Menu
            </button>
            {isOpen && (
                <ul className="dropdown-menu">
                    <li>Option 1</li>
                    <li>Option 2</li>
                    <li>Option 3</li>
                </ul>
            )}
        </div>
    );
};

Pattern 7: Debouncing

Debounce Search Input

const SearchWithDebounce: React.FC = () => {
    const [searchTerm, setSearchTerm] = useState('');
    const [debouncedTerm, setDebouncedTerm] = useState('');
    const [results, setResults] = useState<string[]>([]);
    
    // Debounce the search term
    useEffect(() => {
        const timerId = setTimeout(() => {
            setDebouncedTerm(searchTerm);
        }, 500); // Wait 500ms after user stops typing
        
        return () => {
            clearTimeout(timerId);
        };
    }, [searchTerm]);
    
    // Fetch results when debounced term changes
    useEffect(() => {
        if (debouncedTerm) {
            console.log('Searching for:', debouncedTerm);
            // Simulate API call
            setResults([
                `Result 1 for "${debouncedTerm}"`,
                `Result 2 for "${debouncedTerm}"`,
                `Result 3 for "${debouncedTerm}"`
            ]);
        } else {
            setResults([]);
        }
    }, [debouncedTerm]);
    
    return (
        <div>
            <input
                type="search"
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.target.value)}
                placeholder="Search..."
            />
            <ul>
                {results.map((result, i) => (
                    <li key={i}>{result}</li>
                ))}
            </ul>
        </div>
    );
};

๐Ÿ”„ Avoiding Infinite Loops

One of the most common mistakes with useEffect is creating infinite loops. Let's learn how to recognize and avoid them!

How Infinite Loops Happen

graph TD A["๐Ÿ”„ Component Renders"] --> B["โšก Effect Runs"] B --> C["๐Ÿ’พ Effect Updates State"] C --> D["๐Ÿ” State Change Triggers Re-render"] D --> A style A fill:#667eea,stroke:#5a67d8,color:#fff style B fill:#f44336,stroke:#d32f2f,color:#fff style C fill:#f44336,stroke:#d32f2f,color:#fff style D fill:#f44336,stroke:#d32f2f,color:#fff

๐ŸŽฎ Interactive: Will This Effect Loop?

Each button is a real useEffect configuration. Green means it's safe. Red means it either wastes work on every render or spirals into an infinite loop that freezes the tab. Can you predict each one before you click?

useEffect(effect, deps?) // the deps decide your fate

๐Ÿ‘† Predict, then click. Green = safe, red = wasteful or an infinite loop.

Common Infinite Loop Scenarios

โš ๏ธ Scenario 1: No Dependency Array

// โŒ INFINITE LOOP!
const BadComponent: React.FC = () => {
    const [count, setCount] = useState(0);
    
    useEffect(() => {
        setCount(count + 1); // Updates state
    }); // No dependency array = runs after EVERY render
    
    return <div>{count}</div>;
};

// What happens:
// 1. Component renders
// 2. Effect runs, updates count
// 3. State change causes re-render
// 4. Effect runs again (no deps)
// 5. Back to step 2... forever! ๐Ÿ’ฅ

// โœ… FIX: Add empty dependency array
useEffect(() => {
    setCount(count + 1);
}, []); // Runs once on mount only

โš ๏ธ Scenario 2: Missing Dependencies (with state update)

// โŒ INFINITE LOOP!
const BadComponent: React.FC = () => {
    const [count, setCount] = useState(0);
    const [data, setData] = useState(null);
    
    useEffect(() => {
        fetch('/api/data')
            .then(res => res.json())
            .then(result => setData(result));
    }, [data]); // data is a dependency!
    
    return <div>{data}</div>;
};

// What happens:
// 1. Effect runs, fetches data
// 2. setData updates data
// 3. data changed, so effect runs again
// 4. Fetches again, updates data again
// 5. Infinite loop! ๐Ÿ’ฅ

// โœ… FIX: Remove data from dependencies
useEffect(() => {
    fetch('/api/data')
        .then(res => res.json())
        .then(result => setData(result));
}, []); // Fetch only once on mount

โš ๏ธ Scenario 3: Object/Array Dependencies

// โŒ INFINITE LOOP!
const BadComponent: React.FC = () => {
    const [user, setUser] = useState({ name: 'Alice', age: 30 });
    
    useEffect(() => {
        // Create new object
        setUser({ name: 'Alice', age: 30 });
    }, [user]); // user is new object every time!
    
    return <div>{user.name}</div>;
};

// What happens:
// 1. Effect runs
// 2. setUser creates NEW object
// 3. New object !== old object (different reference)
// 4. Effect sees change, runs again
// 5. Creates another new object... ๐Ÿ’ฅ

// โœ… FIX 1: Only depend on specific properties
useEffect(() => {
    setUser({ name: 'Alice', age: 30 });
}, [user.name, user.age]); // Primitives, not object

// โœ… FIX 2: Don't include in dependencies if not needed
useEffect(() => {
    // If user doesn't actually trigger the effect...
    console.log('Component mounted');
}, []); // Empty array

โš ๏ธ Scenario 4: Function Dependencies

// โŒ INFINITE LOOP!
const BadComponent: React.FC = () => {
    const [count, setCount] = useState(0);
    
    const incrementCount = () => {
        setCount(prev => prev + 1);
    };
    
    useEffect(() => {
        incrementCount();
    }, [incrementCount]); // New function every render!
    
    return <div>{count}</div>;
};

// โœ… FIX 1: Define function inside effect
useEffect(() => {
    const incrementCount = () => {
        setCount(prev => prev + 1);
    };
    incrementCount();
}, []); // No external dependencies

// โœ… FIX 2: Use useCallback for function
const incrementCount = useCallback(() => {
    setCount(prev => prev + 1);
}, []); // Memoized function

useEffect(() => {
    incrementCount();
}, [incrementCount]); // Same function reference

Debugging Infinite Loops

๐Ÿ’ก How to Find the Problem

  1. Check the Console: Look for "Maximum update depth exceeded" error
  2. Add Logging: Log when effect runs
    useEffect(() => {
        console.log('Effect running with deps:', { count, data });
        // Your effect code
    }, [count, data]);
  3. Check Dependencies: Are you updating something in the dependency array?
  4. Use React DevTools: Profiler can show re-render patterns
  5. Comment Out Code: Temporarily disable state updates to isolate the issue

Prevention Strategies

โœ… Best Practices to Avoid Loops

  • Always use dependency arrays: Never omit the second argument
  • Be careful with state updates: Don't update states that are dependencies
  • Use functional updates: setState(prev => ...) when depending on previous state
  • Memoize objects/arrays: Use useMemo/useCallback for complex dependencies
  • Depend on primitives: Use specific properties instead of whole objects
  • Understand reference equality: Objects/arrays/functions are compared by reference
  • Let ESLint help: Enable exhaustive-deps rule

Safe Pattern Examples

โœ… Correct Patterns

// Pattern 1: Fetch data once
useEffect(() => {
    fetch('/api/data')
        .then(res => res.json())
        .then(setData);
}, []); // Empty array = once on mount

// Pattern 2: Update based on prop
useEffect(() => {
    if (userId) {
        fetchUser(userId);
    }
}, [userId]); // Only when userId changes

// Pattern 3: Conditional state update
useEffect(() => {
    if (count > 10 && !hasShownAlert) {
        alert('Count is over 10!');
        setHasShownAlert(true); // Update different state
    }
}, [count, hasShownAlert]); // Won't loop if condition prevents it

// Pattern 4: Effect doesn't update its dependencies
const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');

useEffect(() => {
    const timer = setTimeout(() => {
        setDebouncedInput(input); // Updates different state
    }, 500);
    return () => clearTimeout(timer);
}, [input]); // input changes, but we don't update it

๐Ÿ‹๏ธ Hands-on Practice

Let's put everything together with comprehensive exercises!

๐Ÿ‹๏ธ Exercise 1: Real-Time Clock

Build a clock component that shows the current time and updates every second.

Requirements:

  • Display current time in HH:MM:SS format
  • Update every second
  • Clean up interval when component unmounts
  • Show date as well

Starter Code:

const Clock: React.FC = () => {
    const [time, setTime] = useState(new Date());
    
    // Your code here!
    
    const formatTime = (date: Date): string => {
        return date.toLocaleTimeString();
    };
    
    const formatDate = (date: Date): string => {
        return date.toLocaleDateString();
    };
    
    return (
        <div>
            <h2>{formatTime(time)}</h2>
            <p>{formatDate(time)}</p>
        </div>
    );
};
๐Ÿ’ก Hint

Use setInterval to update the time state every second. Don't forget to clean up!

useEffect(() => {
    const intervalId = setInterval(() => {
        // Update time
    }, 1000);
    
    return () => {
        // Cleanup
    };
}, []);
โœ… Solution
const Clock: React.FC = () => {
    const [time, setTime] = useState(new Date());
    
    useEffect(() => {
        const intervalId = setInterval(() => {
            setTime(new Date());
        }, 1000);
        
        return () => {
            clearInterval(intervalId);
        };
    }, []);
    
    const formatTime = (date: Date): string => {
        return date.toLocaleTimeString();
    };
    
    const formatDate = (date: Date): string => {
        return date.toLocaleDateString();
    };
    
    return (
        <div className="clock">
            <h2>{formatTime(time)}</h2>
            <p>{formatDate(time)}</p>
        </div>
    );
};

๐Ÿ‹๏ธ Exercise 2: Mouse Tracker

Create a component that tracks and displays mouse position.

Requirements:

  • Show X and Y coordinates of mouse
  • Update as mouse moves
  • Clean up event listener on unmount
  • Display if mouse is inside a specific area

Starter Code:

interface MousePosition {
    x: number;
    y: number;
}

const MouseTracker: React.FC = () => {
    const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
    
    // Your code here!
    
    return (
        <div style={{ height: '400px', border: '2px solid #667eea', padding: '20px' }}>
            <h2>Mouse Position</h2>
            <p>X: {position.x}</p>
            <p>Y: {position.y}</p>
        </div>
    );
};
โœ… Solution
const MouseTracker: React.FC = () => {
    const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
    
    useEffect(() => {
        const handleMouseMove = (e: MouseEvent) => {
            setPosition({
                x: e.clientX,
                y: e.clientY
            });
        };
        
        document.addEventListener('mousemove', handleMouseMove);
        
        return () => {
            document.removeEventListener('mousemove', handleMouseMove);
        };
    }, []);
    
    return (
        <div style={{ height: '400px', border: '2px solid #667eea', padding: '20px' }}>
            <h2>Mouse Position</h2>
            <p>X: {position.x}px</p>
            <p>Y: {position.y}px</p>
        </div>
    );
};

๐Ÿ‹๏ธ Exercise 3: Page Title Manager

Build a component that updates the document title based on different states.

Requirements:

  • Update title when notification count changes
  • Show "(N) Notifications" if there are unread items
  • Show app name when count is 0
  • Include page name in title

Starter Code:

const TitleManager: React.FC = () => {
    const [notifications, setNotifications] = useState(0);
    const [pageName, setPageName] = useState('Home');
    
    // Your code here!
    
    return (
        <div>
            <h2>Title Manager</h2>
            <p>Notifications: {notifications}</p>
            <button onClick={() => setNotifications(notifications + 1)}>
                Add Notification
            </button>
            <button onClick={() => setNotifications(0)}>
                Clear Notifications
            </button>
            <select value={pageName} onChange={(e) => setPageName(e.target.value)}>
                <option value="Home">Home</option>
                <option value="Profile">Profile</option>
                <option value="Settings">Settings</option>
            </select>
        </div>
    );
};
โœ… Solution
const TitleManager: React.FC = () => {
    const [notifications, setNotifications] = useState(0);
    const [pageName, setPageName] = useState('Home');
    
    useEffect(() => {
        let title = 'My App';
        
        if (notifications > 0) {
            title = `(${notifications}) ${title}`;
        }
        
        title = `${title} - ${pageName}`;
        
        document.title = title;
    }, [notifications, pageName]);
    
    return (
        <div>
            <h2>Title Manager</h2>
            <p>Notifications: {notifications}</p>
            <button onClick={() => setNotifications(notifications + 1)}>
                Add Notification
            </button>
            <button onClick={() => setNotifications(0)}>
                Clear Notifications
            </button>
            <select value={pageName} onChange={(e) => setPageName(e.target.value)}>
                <option value="Home">Home</option>
                <option value="Profile">Profile</option>
                <option value="Settings">Settings</option>
            </select>
        </div>
    );
};

๐Ÿ‹๏ธ Challenge Exercise: Pomodoro Timer

Build a complete Pomodoro timer with work and break sessions.

Requirements:

  • 25-minute work sessions
  • 5-minute break sessions
  • Auto-switch between work and break
  • Play sound when timer completes
  • Show notification when switching
  • Pause/resume functionality
  • Reset functionality

Bonus: Update document title with remaining time, save state to localStorage, add customizable durations!

โœจ Best Practices

โœ… Do's

  • Always use dependency arrays: Make effect behavior explicit
  • Clean up side effects: Remove listeners, clear timers, cancel requests
  • Keep effects focused: One effect per concern
  • Use early returns: Exit effect early when condition isn't met
  • Extract to custom hooks: Reuse effect logic across components
  • Type your effects: Use TypeScript for better safety
  • Use ESLint rule: Enable exhaustive-deps for automatic checks
  • Log during development: Add console.logs to understand execution
  • Consider effect timing: Use useLayoutEffect when DOM measurements needed

โŒ Don'ts

  • Don't omit dependency arrays: Leads to unpredictable behavior
  • Don't ignore ESLint warnings: They usually indicate real problems
  • Don't make effect function async: Define async function inside instead
  • Don't update state dependencies: Causes infinite loops
  • Don't forget cleanup: Memory leaks are hard to debug
  • Don't put too much in one effect: Split into multiple focused effects
  • Don't depend on objects/arrays directly: Use specific properties
  • Don't use effects for derived state: Calculate during render instead

Effect Organization

โœ… Good: Separate Concerns

const MyComponent: React.FC = () => {
    // Effect 1: Document title
    useEffect(() => {
        document.title = `Page ${page}`;
    }, [page]);
    
    // Effect 2: Data fetching
    useEffect(() => {
        fetchData();
    }, [userId]);
    
    // Effect 3: Event listener
    useEffect(() => {
        window.addEventListener('resize', handleResize);
        return () => window.removeEventListener('resize', handleResize);
    }, []);
    
    // Each effect has single responsibility
};

โŒ Bad: Everything in One Effect

const MyComponent: React.FC = () => {
    useEffect(() => {
        // Too much in one effect!
        document.title = `Page ${page}`;
        fetchData();
        window.addEventListener('resize', handleResize);
        
        return () => {
            window.removeEventListener('resize', handleResize);
        };
    }, [page, userId]); // Runs for both changes
};

๐Ÿค” You Might Not Need an Effect

This deserves its own heading because it's the mistake even experienced developers make most often. The React team wrote an entire documentation page called "You Might Not Need an Effect," and the message is simple: a huge fraction of effects in real codebases shouldn't be effects at all. Two patterns cover most cases.

1. Derived state โ€” calculate it during render. If a value can be computed from existing props or state, just compute it. Don't mirror it into extra state and sync it with an effect; that's an extra render and one more thing to keep in sync.

2. Responding to a user action โ€” put it in the event handler. If something should happen because the user clicked, do it in the onClick. Routing state through an effect that "watches" a flag is indirect, harder to follow, and runs one render too late.

๐Ÿ’ก Consider Alternatives

// โŒ DON'T: Use effect for derived state
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);

useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price, 0));
}, [items]);

// โœ… DO: Calculate during render
const [items, setItems] = useState([]);
const total = items.reduce((sum, item) => sum + item.price, 0);

// โŒ DON'T: Use effect to transform props
useEffect(() => {
    setUpperName(name.toUpperCase());
}, [name]);

// โœ… DO: Transform during render
const upperName = name.toUpperCase();

// โŒ DON'T: Use effect for event handlers
useEffect(() => {
    if (shouldSubmit) {
        submitForm();
    }
}, [shouldSubmit]);

// โœ… DO: Call directly in event handler
const handleClick = () => {
    submitForm();
};

Performance Considerations

Optimize Effect Execution

// โŒ Runs on every render
useEffect(() => {
    expensiveOperation();
});

// โœ… Runs only when needed
useEffect(() => {
    expensiveOperation();
}, [dependency]);

// โœ… Early return for unnecessary work
useEffect(() => {
    if (!shouldRun) return;
    expensiveOperation();
}, [shouldRun, dependency]);

๐Ÿ“š Summary

What You Learned

Congratulations! You've mastered the useEffect hookโ€”one of React's most powerful and essential features:

  • โœ… Understanding side effects and why they need special handling
  • โœ… Using useEffect to run code after renders
  • โœ… Mastering dependency arrays for controlling when effects run
  • โœ… Implementing cleanup functions to prevent memory leaks
  • โœ… Understanding effect execution timing and lifecycle
  • โœ… Common patterns: timers, event listeners, document updates
  • โœ… Avoiding infinite loops and other common pitfalls
  • โœ… Writing clean, maintainable effects with TypeScript

๐ŸŽฏ Key Takeaways

  • An effect is for synchronization: reach for it only when you're syncing with something outside Reactโ€”and ask "do I need an effect at all?" first
  • Pure render functions: side effects go in useEffect, not in the render body
  • Dependency arrays matter: they control when effects runโ€”keep them exhaustive
  • Always clean up: return a cleanup to prevent leaks, stale updates, and race conditions
  • StrictMode's double-run is a feature: if your cleanup is correct, it's harmless
  • For data fetching, prefer a library (TanStack Query) or a framework/Server Component over raw useEffect

useEffect Quick Reference

Pattern Code When It Runs
Every Render useEffect(() => {}) After every render
Mount Only useEffect(() => {}, []) Once when component mounts
On Change useEffect(() => {}, [dep]) When dep changes
With Cleanup useEffect(() => { return () => {} }, []) Cleanup on unmount/re-run

Common Use Cases Cheatsheet

// Document title
useEffect(() => {
    document.title = title;
}, [title]);

// Timer
useEffect(() => {
    const id = setInterval(() => {}, 1000);
    return () => clearInterval(id);
}, []);

// Event listener
useEffect(() => {
    window.addEventListener('event', handler);
    return () => window.removeEventListener('event', handler);
}, []);

// Focus input
useEffect(() => {
    inputRef.current?.focus();
}, []);

// localStorage sync
useEffect(() => {
    localStorage.setItem('key', value);
}, [value]);

๐Ÿš€ What's Next?

In the next lesson, we'll learn about Data Fetching Basics:

  • Using fetch API with useEffect
  • Handling loading and error states
  • Typing API responses with TypeScript
  • Async/await patterns in effects
  • Canceling requests with AbortController
  • Building real-world data fetching components

You're building real React applications now! ๐Ÿ’ช