On this page
useEffect and Lifecycle: Synchronizing with the Outside World
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:
- Runs the cleanup for the previous effect (closes the old socket).
- 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
keyprop 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.
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;
Sign in to track your progress