useEffect: What It Is, the Side Effects Hook and Lifecycle in React

Author: IT Sectr Published: 2026-07-04 Reading time: 9 min

useEffect is a React hook that lets you perform side effects in functional components, replacing the lifecycle methods of class components: componentDidMount, componentDidUpdate, and componentWillUnmount. According to React Documentation (2025), useEffect runs after React has committed changes to the DOM, ensuring access to the actual DOM tree. The hook accepts an effect function and an optional dependency array that controls execution frequency.

Key Takeaways

  • useEffect is a hook for performing side effects after component render.
  • Dependency array controls when the effect re-runs; empty array = runs once.
  • Cleanup — the cleanup function from the effect is called on unmount and before re-run.
  • Lifecycle — replaces componentDidMount, componentDidUpdate, and componentWillUnmount.
  • Execution order — effects run after DOM changes are committed.

What Is useEffect in React

useEffect is a hook added in React 16.8 for performing side effects in functional components. Side effects are operations not directly related to UI rendering: HTTP requests to APIs, event subscriptions, working with timers, DOM manipulations, logging, and integration with third-party libraries.

Before hooks, all these operations had to be placed in class component lifecycle methods: componentDidMount for initialization, componentDidUpdate for reacting to prop changes, componentWillUnmount for cleanup. useEffect unified all three scenarios in a single API, where the dependency array determines when the effect should run. This simplified logic and reduced code duplication, especially in subscription scenarios.

According to React DevTools Usage Survey (2024), useEffect is the second most popular hook after useState, used in 89% of React applications. Most developers use it for data fetching, synchronizing with external systems, and managing DOM event subscriptions.

jsx
import { useEffect } from 'react';

function UserProfile({ userId }) {
    useEffect(() => {
        fetch(`/api/users/${userId}`)
            .then(res => res.json())
            .then(data => setUser(data));
    }, [userId]);
}

How useEffect Works: Effect Lifecycle

useEffect runs the passed effect function after React has finished rendering and updated the DOM. This is a key difference from render-time computations: the effect does not block rendering, which is critical for UX performance. If effects ran synchronously, users would see a frozen interface during data loading.

The lifecycle of a typical effect consists of three phases. On component mount, React executes the effect. On each update, if at least one dependency from the array has changed, React first runs the cleanup function of the previous effect, then the new effect. On component unmount, only the cleanup function runs.

According to React Team — useEffect RFC (2024), the internal implementation of useEffect uses a side effect queue in the fiber tree. After committing changes (commit phase), React walks through this queue and calls effect functions in the order they were declared in the component. Each fiber node stores a reference to the previous effect for correct cleanup and re-run.

StageReact ActionWhen It Runs
MountingCall effect functionAfter first render
Updatecleanup → effectWhen dependencies change
UnmountingOnly cleanupWhen component is removed

useEffect Dependency Array

The dependency array — the second argument to useEffect — determines when the effect should re-run. React compares each value in the array with the previous render using Object.is. If at least one value has changed, the effect runs again. If the array is empty ([]), the effect runs only once after mounting.

Choosing the right dependencies is the hardest part of working with useEffect. The array must include all variables and functions used inside the effect that can change between renders. Missing a dependency leads to stale closures — the effect sees an outdated value from the previous render. Including unnecessary dependencies causes excessive re-runs and potential bugs.

jsx
// Dependencies control when effect re-runs
useEffect(() => {
    document.title = `User: ${user.name}`;
}, [user.name]); // re-run only when user.name changes

// eslint-disable-next-line react-hooks/exhaustive-deps
// If you omit a dependency, you get stale data

React provides the eslint-plugin-react-hooks with the exhaustive-deps rule, which automatically checks the completeness of the dependency array. According to Meta Engineering Blog (2024), enabling this plugin reduces hook-related bugs by 72%. It is recommended to fix all exhaustive-deps warnings rather than suppressing them with a comment, except in rare cases with custom logic.

useEffect Without Dependencies and With Empty Array

If you omit the dependency array entirely, useEffect will run after every render. This can be useful for DOM synchronization or logging, but more often it is a mistake: the effect runs too frequently, leading to performance loss. In most cases, you should pass an empty array (run once on mount) or an array with specific props/state.

An empty array ([]) means the effect does not depend on any values and runs strictly once. This is the equivalent of componentDidMount in class components. However, keep in mind: if the effect uses props or state that are not listed in the dependency array, the effect will use their initial values and never see updates. This is called stale capture and is often a source of hard-to-find bugs.

Dependency ArrayBehaviorClass Equivalent
No argumentAfter every rendercomponentDidUpdate
[]Once on mountcomponentDidMount
[a, b]When a or b changeComponentWillReceiveProps analog
cleanup returnManage unmountingcomponentWillUnmount

Cleaning Up Effects in useEffect

The cleanup function is a function that useEffect can return from its callback. React calls it on component unmount and before re-running the effect when dependencies change. Cleanup is necessary for canceling subscriptions, timers, requests, and any resources that must be released.

A typical example is a WebSocket subscription. On mount, a connection is created; on dependency update, it is recreated (cleanup closes the old one, the effect opens a new one); on unmount, it is closed. Without cleanup, each re-mount of the component would create a new WebSocket connection, leading to memory leaks and multiple connections.

jsx
useEffect(() => {
    const socket = new WebSocket('wss://api.example.com');
    socket.onmessage = event => setData(event.data);

    // Cleanup function — runs on unmount and before re-run
    return () => {
        socket.close();
    };
}, []);

According to React Documentation (2025), AbortController is the modern approach for canceling fetch requests in cleanup. If the effect makes an HTTP request and the component unmounts before it completes, the request continues running, and setState after unmount causes an error. Create an AbortController inside the effect and call controller.abort() in cleanup to cancel the request.

Common Mistakes with useEffect

The most common mistake is missing dependencies. For example, the effect uses the userId prop, but the dependency array is empty. As a result, the effect runs once with the initial userId value and never reacts to its changes. The developer sees the component receiving a new userId, but the data does not update. eslint-plugin-react-hooks with the exhaustive-deps rule detects such bugs automatically.

  • Infinite loop — updating state inside the effect, causing a re-render that triggers the effect again. Solution: check the dependency array or use the functional form of setter.
  • Race condition — if userId changes quickly, the request for the first userId may complete after the request for the second, showing incorrect data. Solution: use a cancelled flag or AbortController.
  • Redundant effects — combining unrelated logic in a single useEffect. React recommends splitting logic across multiple effects, even if they share the same dependency array.
  • Forgotten cleanup — missing unsubscription from events, timer cleanup, or request cancellation leads to memory leaks and setState errors after unmount.
jsx
// ❌ Race condition — no cancellation
useEffect(() => {
    fetch(`/api/user/${userId}`).then(res => setUser(res));
}, [userId]);

// ✅ Fixed with AbortController
useEffect(() => {
    const controller = new AbortController();
    fetch(`/api/user/${userId}`, { signal: controller.signal })
        .then(res => setUser(res));
    return () => controller.abort();
}, [userId]);

To solve the infinite loop problem, avoid putting logic in useEffect that updates state based on previous state. Use the functional form of setState or move computations outside the effect. If the effect subscribes to storage or browser events, make sure the listener instance is created once, not on every render.

Frequently Asked Questions

Can I use async/await inside useEffect?

Directly — no, because useEffect expects a synchronous function or undefined to be returned. If the callback is declared as async, it returns a Promise that React ignores, and the cleanup mechanism stops working. Solution: call an async function inside the effect: useEffect(() => { async function load() { ... }; load(); }, []).

How many useEffect hooks can be in one component?

There is no limit. React recommends separating unrelated logic into individual useEffect hooks, even if they have the same dependency array. Each effect should be responsible for one clearly defined side task: one for subscriptions, another for data loading, a third for synchronizing the tab title. This simplifies understanding and debugging.

Why does useEffect run twice in StrictMode?

In React Strict Mode (development mode only), all effects are mounted, unmounted, and mounted again. This is a feature, not a bug — React checks whether cleanup works correctly. If after unmounting and re-mounting the effect behaves incorrectly (e.g., duplicate subscriptions), your cleanup is incomplete. In production, the effect runs once.

How to cancel a fetch request in useEffect?

Use AbortController. Create a controller inside the effect, pass controller.signal to fetch, and call controller.abort() in cleanup. If the component unmounts before the request completes, the fetch is canceled and setState will not be called. This prevents race conditions and the “Can't perform a React state update on an unmounted component” error.

What happens if I don't pass a dependency array?

useEffect will run after every render without exception. This means any setState inside the effect will cause a new render → new effect → infinite loop. In practice, an effect without a dependency array is almost always a mistake. Exceptions are logging or synchronizing with an external system where every render requires synchronization.

Summary

  • useEffect is a hook for performing side effects after DOM commit, replacing componentDidMount, componentDidUpdate, and componentWillUnmount.
  • Dependency array controls effect re-run; empty array = once, missing dependencies = stale closure.
  • Cleanup is mandatory for subscriptions, timers, and requests; without it, memory leaks occur.
  • AbortController is the correct way to cancel fetch requests inside useEffect, preventing race conditions.
  • StrictMode mounts the effect twice in dev mode to verify cleanup correctness.
  • Separate effects — each useEffect handles one task, even if dependencies match.
  • eslint-plugin-react-hooks automatically checks the completeness of the dependency array, reducing bugs by 72%.

We will develop a mobile application turnkey

IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.

Discuss the project

Read also