Managing side effects in React components can quickly become a tangled mess of event listeners, API calls, and state updates. Developers struggle with memory leaks, race conditions, and excessive re-renders—issues that drain performance and complicate debugging.
What is useEffect
? React's useEffect Hook provides a structured approach to handling side effects that keeps components predictable and maintainable. This guide covers everything from basic implementations to advanced patterns, with practical examples for production code.
Whether you're new to React Hooks or refining your side-effect management, you'll learn actionable techniques for data fetching, subscriptions, and DOM interactions that prevent common pitfalls.
In Brief:
useEffect
manages side effects in functional components, replacing class lifecycle methods with a unified Hook- Dependency arrays control when effects run—empty arrays run once, populated arrays run on specific changes
- Proper cleanup functions prevent memory leaks from event listeners, timers, and network requests
- Custom hooks extract common effect patterns for reusability across components
What is useEffect?
useEffect
is React's built-in Hook for managing side effects like data fetching, subscriptions, and DOM manipulation in function components. The Hook gives you direct access to the latest props and state through JavaScript closures.
After React commits DOM changes, it executes every registered effect, providing a predictable window to sync external systems with the rendered UI.
Before Hooks, you needed three separate lifecycle methods—componentDidMount
, componentDidUpdate
, and componentWillUnmount
—to accomplish the same work. The Hook replaces and unifies these methods. When the dependency array changes, it re-runs, and its optional cleanup function handles unmount logic.
The Hook's signature is straightforward:
1useEffect(callback, dependencyArray?)
The callback runs after each render that satisfies the dependency rules. The dependencyArray
determines when React should re-invoke the callback. Omit it to run on every render, pass []
to run once, or list specific values to run only when they change.
React guarantees that useEffect
executes after the browser has painted the DOM, which keeps the main thread free for rendering, but it does not prevent layout flashes. For preventing layout flashes, useLayoutEffect
should be used, as it runs before the browser paints the next frame.
Here's how different dependency patterns map to class lifecycles:
Run Once After Mount
useEffect(callback, [])
with an empty dependency array replicates componentDidMount
behavior. The effect executes once after the initial render and never again, making it perfect for one-time setup operations like API calls or event listener registration.
Run on Specific Changes
useEffect(callback, [deps])
with specific dependencies mirrors conditional componentDidUpdate
logic. The effect only runs when listed dependencies change, giving you precise control over when side effects execute.
Run After Every Render
useEffect(callback)
without a dependency array runs after every render. This pattern has no direct class component equivalent since it would require logic spread across multiple lifecycle methods. Use this sparingly, as it can impact performance.
Cleanup Operations
The cleanup function returned by any effect replaces componentWillUnmount
logic. React calls this function before the effect runs again and before component unmount, keeping cleanup colocated with setup for better maintainability.
You can declare multiple effects to keep unrelated concerns isolated—one effect handles a WebSocket subscription while another updates the document title. This separation leads to smaller, easier-to-reason-about functions.
Basic Syntax Example
1import { useEffect, useState } from 'react';
2
3function Timer() {
4 const [seconds, setSeconds] = useState(0);
5
6 useEffect(() => {
7 // 1️⃣ setup: start interval after initial render
8 const id = setInterval(() => setSeconds(s => s + 1), 1000);
9
10 // 2️⃣ cleanup: clear interval before unmount or before re-running effect
11 return () => clearInterval(id);
12 }, []); // 3️⃣ dependency array: empty → run once
13
14 return <p>Elapsed: {seconds}s</p>;
15}
The callback establishes the side effect (a one-second interval). The function it returns tears the interval down, preventing memory leaks. Passing []
tells React to set up and later tear down the interval only once, replicating componentDidMount
and componentWillUnmount
behavior in a single block.
Core useEffect Use Cases for Modern React
Let's explore the most common side effect scenarios you'll encounter in React applications.
Data Fetching
Data fetching is the most common side effect in React applications. The hook runs after the DOM paints, so network latency never blocks the initial render, and the dependency array ensures requests fire only when needed. Since the effect callback can't be async, wrap your await logic in an inner function and call it immediately.
1import { useState, useEffect } from 'react';
2
3function UsersList({ url }) {
4 const [users, setUsers] = useState([]);
5 const [loading, setLoading] = useState(true);
6 const [error, setError] = useState(null);
7
8 useEffect(() => {
9 const abortController = new AbortController();
10
11 const fetchUsers = async () => {
12 try {
13 const res = await fetch(url, { signal: abortController.signal });
14 if (!res.ok) throw new Error('Network response was not ok');
15 const data = await res.json();
16 setUsers(data);
17 } catch (err) {
18 if (err.name !== 'AbortError') setError(err.message);
19 } finally {
20 setLoading(false);
21 }
22 };
23
24 fetchUsers();
25 return () => abortController.abort(); // cancel in-flight requests
26 }, [url]);
27
28 /* render state here */
29}
Using AbortController
prevents race conditions where slow requests finish after navigation. With loading and error state handled locally, you avoid global flags and keep components self-contained.
Subscriptions
Whenever you listen to browser events, WebSockets, or custom emitters, you're dealing with subscriptions. Subscribe in the effect and unsubscribe in the cleanup to prevent memory leaks after re-renders or unmounts.
1import { useState, useEffect } from 'react';
2
3function WindowWidth() {
4 const [width, setWidth] = useState(window.innerWidth);
5
6 useEffect(() => {
7 const handleResize = () => setWidth(window.innerWidth);
8
9 window.addEventListener('resize', handleResize);
10 handleResize(); // set initial width
11
12 return () => window.removeEventListener('resize', handleResize);
13 }, []); // runs once
14
15 return <span>{width}px</span>;
16}
Whether you swap window
for a WebSocket instance, media query listener, or custom event bus, the pattern stays identical: open the connection, update state in the handler, and tear everything down in the return function. Keep the dependency array minimal ([]
here) to avoid unnecessary re-subscriptions.
DOM Manipulation
React prefers declarative UI, but some scenarios need imperative touches—setting focus for accessibility, integrating charting libraries that mutate canvas, or updating document.title
. Since the Hook runs after the DOM is ready, it's the safe place for these mutations.
1import { useEffect } from 'react';
2
3function Counter({ count }) {
4 useEffect(() => {
5 document.title = `Count: ${count}`;
6 }, [count]); // update only when count changes
7
8 return <h1>{count}</h1>;
9}
When working with refs—auto-focusing an input, for example—call ref.current.focus()
inside an effect with an empty dependency array to run once. When integrating third-party libraries, pair initialization with cleanup that destroys observers or disposes instances, preventing stray nodes or duplicated listeners on hot reload.
Cleanup Operations
Every side effect should leave the component in the same state it found it. The cleanup function returned by the Hook handles this. React calls that function before the effect runs again and before the component unmounts, mirroring componentWillUnmount
but colocated for clarity.
1useEffect(() => {
2 const id = setInterval(() => setTick(t => t + 1), 1000);
3
4 return () => clearInterval(id); // stop the timer
5}, []);
Apply this pattern broadly—removing event listeners, clearing timeouts, closing WebSockets, aborting fetches—to keep memory usage predictable and avoid "can't perform a React state update on an unmounted component" warnings. Think of cleanup as the safety net that lets you fearlessly wire external systems to your UI while keeping React in control.
useEffect Dependency Array
The dependency array controls when React reruns a side effect. Understanding this array prevents performance issues and debugging nightmares around race conditions or infinite loops.
When you omit the dependency array entirely, the effect runs after every render. This pattern is rarely what you want:
1useEffect(() => {
2 console.log('fires on every render');
3});
Because the effect executes after every update, any state change inside the effect can immediately retrigger it, creating an infinite loop.
An empty dependency array []
runs the effect once on mount, mimicking componentDidMount
. This pattern works well for one-time setup like analytics or a single API call:
1useEffect(() => {
2 fetchInitialData();
3}, []);
Confirm you don't reference any changing variables inside the effect; otherwise you'll read stale values.
A populated array [dep1, dep2]
runs when specific values change, providing precise control over when effects execute:
1useEffect(() => {
2 updateChart(data, options);
3}, [data, options]);
React compares each dependency with its previous value using Object.is()
semantics. If at least one value differs, the cleanup (if any) runs first, then the effect executes with fresh values. This mirrors componentDidUpdate
but with far finer control.
Because values are compared by reference, passing newly created objects or inline functions will fool React into thinking something changed every render. Memoize those with useMemo
or useCallback
to keep the effect quiet.
Stale closures and missing dependencies
A common pitfall is omitting a variable you read or write inside the effect:
1// BUG: count isn't in the array
2useEffect(() => {
3 if (count > 10) console.log('high');
4}, []); // stale count captured at mount
React captures the initial count
and never sees updates, producing confusing behavior. Add the variable—or restructure your logic—and the problem disappears:
1useEffect(() => {
2 if (count > 10) console.log('high');
3}, [count]);
Unnecessary dependencies
The opposite mistake is over-specifying. Imagine listening to window resize events:
1// handler recreated on every render -> effect re-runs needlessly
2useEffect(() => {
3 const handler = () => setWidth(window.innerWidth);
4 window.addEventListener('resize', handler);
5 return () => window.removeEventListener('resize', handler);
6}, [handler]);
Wrap handler
in useCallback
or define it inside the effect to avoid continual re-subscriptions.
ESLint to the rescue
Enable the exhaustive-deps
rule from eslint-plugin-react-hooks
. It highlights missing dependencies and warns when you add ones that aren't actually used, significantly reducing mental overhead.
Mastering these patterns turns the Hook from an unpredictable black box into a precise tool for orchestrating side effects without sacrificing performance or creating memory leaks.
How to Use useEffect Without Breaking Your App
Below are some proven techniques for managing side effects efficiently in your React applications.
Create a useFetch Hook
Building a small useFetch
hook frees you from rewriting the same boilerplate in every component. Inside the hook, wrap the asynchronous call in an inner function because the effect callback can't be async
itself.
Create an AbortController
to cancel in-flight requests during unmount or rapid prop changes. The cleanup function calls controller.abort()
, which guards against state updates on an unmounted component.
1import { useState, useEffect } from 'react';
2
3export function useFetch(url) {
4 const [data, setData] = useState(null);
5 const [error, setError] = useState(null);
6 const [loading, setLoading] = useState(false);
7
8 useEffect(() => {
9 const controller = new AbortController();
10 const fetchData = async () => {
11 setLoading(true);
12 try {
13 const res = await fetch(url, { signal: controller.signal });
14 if (!res.ok) throw new Error(res.statusText);
15 setData(await res.json());
16 } catch (err) {
17 if (err.name !== 'AbortError') setError(err.message);
18 } finally {
19 setLoading(false);
20 }
21 };
22 fetchData();
23 return () => controller.abort();
24 }, [url]);
25
26 return { data, error, loading };
27}
Because the hook memoizes nothing but primitives, React's change detection stays cheap. When you need caching, layer useMemo
or a simple in-memory map on top; the data then persists across renders without extra network traffic.
Avoid Infinite Loops
An effect that updates a value it also depends on will trigger an endless render cycle. Here's the classic mistake:
1// ⚠️ Endless loop
2useEffect(() => setCount(count + 1));
Every state change re-fires the effect because no dependency array exists. Add the dependency array—or move the update out of the Hook entirely—to break the cycle:
1// ✅ Stable
2useEffect(() => setCount(count + 1), []); // runs once
More subtle loops arise when functions or objects recreated on every render sit in the dependency list. Wrap them in useCallback
or useMemo
so their references stay stable.
For fetches, the "fetch-and-ignore" pattern is safer: set a local ignore
flag in the cleanup, then bail out early if the response resolves late.
Use Custom Hooks to Reuse Effect Logic
Extracting common effect patterns into custom hooks keeps components focused on UI. Three hooks that prove consistently useful are useLocalStorage for syncing state with localStorage to persist user preferences, useWindowSize
for tracking resize
events and returning { width, height }
, and useDebounce
for delaying rapidly changing values, perfect for search inputs.
1import { useState, useEffect } from 'react';
2
3export function useDebounce(value, delay = 300) {
4 const [debounced, setDebounced] = useState(value);
5
6 useEffect(() => {
7 const id = setTimeout(() => setDebounced(value), delay);
8 return () => clearTimeout(id);
9 }, [value, delay]);
10
11 return debounced;
12}
Each hook encapsulates setup and cleanup, so components stay declarative while complex side-effect logic remains testable and shareable.
Optimize Performance
Not every keystroke deserves a network request or expensive calculation. Debouncing with the useDebounce
hook above cuts API chatter to one call per pause instead of one per keypress. For scroll or resize handlers, throttle the callback and remove the listener in cleanup to keep memory usage flat.
When an effect is conditional—say, only fetch when query.length > 2
—add a guard at the top of the effect or bail out early. React's exhaustive-deps ESLint rule warns when dependencies are missing; heed it, but silence false positives by memoizing stable references. Profiling often shows dramatic gains once redundant effects disappear.
A quick before/after: an effect without a dependency array re-renders a chart on every hover; adding [data]
drops CPU usage while keeping visuals current.
How Strapi Enables React useEffect Best Practices
Strapi's REST and GraphQL APIs pair cleanly with the Hook; every Collection Type exposes predictable endpoints, so you can generate reusable hooks without special-casing each model.
1function useArticles(page = 1) {
2 const url = `http://localhost:1337/api/articles?pagination[page]=${page}&populate=*`;
3 return useFetch(url); // the generic hook from earlier
4}
Strapi's consistent filtering, sorting, and pagination mean the only variable is the query string. Permission settings in the Admin Panel ensure that unauthenticated users see only public data—no extra checks are performed inside your effect.
When you need real-time updates, combine Strapi's webhook support with a simple SSE or WebSocket subscription managed in the Hook; the cleanup phase closes the connection to avoid orphan sockets.
Because each endpoint returns lean JSON, network payloads stay small, which keeps the dependency array minimal and reduces re-renders. Whether you build a useStrapi
helper that injects JWT headers or a hook per Content-Type, Strapi's uniform API surface lets you follow the React best practices above without extra glue code.
Debugging Common useEffect Mistakes
You'll avoid most headaches with the Hook once you learn to spot these traps.
Fix Missing Dependencies to Avoid Stale Closures
Stale closures lead to effects that reference outdated values. When you forget to include a dependency in your array, your effect captures the value from when it first ran, not the current value:
1// ❌ logs outdated count
2useEffect(() => {
3 console.log(count);
4}, []); // forgot to add `count`
The fix is straightforward—add every value you read or write:
1// ✅ always logs fresh count
2useEffect(() => {
3 console.log(count);
4}, [count]);
React's exhaustive-deps ESLint rule flags these omissions automatically when enabled, which saves you from debugging stale closure issues later.
Prevent Infinite Loops with Stable Dependencies
Infinite re-render loops happen when an effect updates a value that's also listed as a dependency. This creates a cycle where the effect runs, updates the dependency, triggers the effect again, and so on:
1// ❌ loops forever
2useEffect(() => setTotal(total + 1));
Stabilize your inputs with a dependency array or use functional updates to break the cycle:
1useEffect(() => setTotal(t => t + 1), []);
Always Clean Up Side Effects on Unmount
Memory leaks surface when you forget cleanup, leaving event listeners, intervals, or subscriptions running after your component unmounts:
1// ❌ listener kept after unmount
2useEffect(() => {
3 window.addEventListener('resize', onResize);
4});
Always return a cleanup function to remove what you added:
1// ✅ cleaned up
2useEffect(() => {
3 window.addEventListener('resize', onResize);
4 return () => window.removeEventListener('resize', onResize);
5}, []);
Avoid Race Conditions in Async Effects
Race conditions arise when a slow fetch resolves after a newer one, potentially overwriting fresh data with stale results. Guard against this with a flag that prevents outdated responses from updating your state:
1useEffect(() => {
2 let ignore = false;
3 fetch(url).then(r => r.json()).then(data => {
4 if (!ignore) setData(data);
5 });
6 return () => { ignore = true; };
7}, [url]);
Don’t Overuse useEffect for Derivable Logic
Overusing the Hook is itself a pitfall. If you can derive a value during render or handle it in an event handler, skip the hook entirely—your component will be simpler and faster. Effects should handle genuine side effects, not replace normal React patterns.
Mastering React Side Effects with useEffect
The Hook provides the standard approach for managing side effects in React functional components. When you combine precise dependency arrays with cleanup functions, you prevent stale data, memory leaks, and infinite-render loops.
The patterns we covered—targeted data fetching, disciplined subscription management, and focused DOM manipulation—keep your code maintainable. Strapi's consistent REST and GraphQL endpoints work directly with these patterns, letting you reuse the same hooks across projects.
Next, profile your effects with React DevTools, extract repeated logic into custom hooks, and explore deeper documentation to build production-ready applications with confident effect management.