← Back to Articles         
useEffect - React Hook

useEffect - React Hook

Published on 11 Nov 2026 31 min read React
React Fundamentals

What is useEffect in React?

useEffect is a React Hook used to perform side effects from a functional component.

A component's main responsibility is to calculate and return UI:

const User = () => {
    return <h2>User Profile</h2>;
};

But real applications need to do things beyond simply generating JSX. For example:

  • Call an API
  • Fetch data from a server
  • Change the browser document title
  • Subscribe to an event
  • Start a timer
  • Listen for window events
  • Connect to WebSocket
  • Read/write browser storage
  • Integrate with a third-party library
  • Perform cleanup when a component is removed
  • React to changes in props or state

These operations are called side effects.

useEffect provides a way to tell React:

"After React has rendered this component, perform this external operation."

The basic syntax is:

useEffect(() => {
    // Side effect
}, []);

You import it from React:

import { useEffect } from "react";

What exactly is a Side Effect?

A side effect is an operation that interacts with something outside the component's rendering calculation.

For example:

const User = ({ name }: { name: string }) => {
    document.title = name;

    return <h2>{name}</h2>;
};

Changing document.title is a side effect because the component is modifying something outside React's rendered JSX.

Instead, use:

const User = ({ name }: { name: string }) => {

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

    return <h2>{name}</h2>;
};

React renders the component first, and then the effect runs.


The Basic Execution Model

A useful way to understand useEffect is:

Component renders
       ↓
React updates the DOM
       ↓
Browser paints the UI
       ↓
useEffect runs
       ↓
External operation happens
       ↓
Something may change
       ↓
Component may render again

For example:

const Counter = () => {

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

    useEffect(() => {
        document.title = `Count: ${count}`;
    }, [count]);

    return (
        <button onClick={() => setCount(count + 1)}>
            Count: {count}
        </button>
    );
};

Initially:

count = 0
   ↓
Component renders
   ↓
document.title = "Count: 0"

When the button is clicked:

count = 1
   ↓
Component renders again
   ↓
React updates UI
   ↓
Effect runs because count changed
   ↓
document.title = "Count: 1"

The important point is that the effect does not run during the rendering calculation itself.


useEffect Syntax

The complete form is:

useEffect(() => {
    // Effect logic

    return () => {
        // Cleanup logic
    };
}, [dependencies]);

There are three important parts:

useEffect(
    effect function,
    dependency array
)

The cleanup function is optional.

For example:

useEffect(() => {

    console.log("Effect executed");

    return () => {
        console.log("Cleanup executed");
    };

}, [count]);

You can think of it as:

useEffect
   │
   ├── Effect function
   │       └── What should happen?
   │
   └── Dependency array
           └── When should it happen again?

The Most Important Rule of useEffect

The dependency array determines when React should re-run the effect.

There are three major forms:

useEffect(() => {
    // ...
});
useEffect(() => {
    // ...
}, []);
useEffect(() => {
    // ...
}, [value]);

These behave differently.


1. useEffect Without a Dependency Array

Example:

useEffect(() => {
    console.log("Effect executed");
});

There is no dependency array.

Therefore, the effect runs after every render.

Example:

const Counter = () => {

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

    useEffect(() => {
        console.log("Effect executed");
    });

    return (
        <button onClick={() => setCount(count + 1)}>
            {count}
        </button>
    );
};

The sequence is:

Initial render
    ↓
Effect runs

Click
    ↓
State changes
    ↓
Component renders
    ↓
Effect runs

Click
    ↓
State changes
    ↓
Component renders
    ↓
Effect runs

So:

useEffect(() => {
    // Runs after every render
});

When is this useful?

This form is less common because it can execute very frequently.

It can be useful when you genuinely need to perform an operation after every render, but you should be careful because expensive work here can hurt performance.


2. useEffect With an Empty Dependency Array

Example:

useEffect(() => {
    console.log("Effect executed");
}, []);

An empty dependency array means:

Run this effect after the component's initial mount.

For example:

const Home = () => {

    useEffect(() => {
        console.log("Home component mounted");
    }, []);

    return <h2>Home Page</h2>;
};

Conceptually:

Component created
       ↓
Initial render
       ↓
DOM updated
       ↓
Effect runs
       ↓
No dependency changes
       ↓
Effect does not run again

This is commonly used for initialization work.

For example:

useEffect(() => {
    loadUsers();
}, []);

Or:

useEffect(() => {
    document.title = "Home";
}, []);

Or:

useEffect(() => {
    localStorage.setItem("visited", "true");
}, []);

Important: Empty Dependency Array Does Not Mean "Only Once" in Every Development Situation

You will often hear:

"useEffect(..., []) runs only once."

That is a useful simplified description for a mounted component, but there is an important development behavior.

With React Strict Mode in development, React may intentionally run the effect setup and cleanup cycle more than once to help detect unsafe side effects.

For example:

useEffect(() => {
    console.log("Effect");

    return () => {
        console.log("Cleanup");
    };
}, []);

You may see development behavior resembling:

Effect
Cleanup
Effect

This does not mean production applications necessarily execute the effect twice.

Therefore, effects should be written so that setup and cleanup are safe and repeatable.


3. useEffect With Dependencies

Example:

useEffect(() => {
    console.log("Count changed");
}, [count]);

Now React watches count.

The effect runs:

  • After the initial render
  • Again when count changes

For example:

const Counter = () => {

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

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

    return (
        <button onClick={() => setCount(count + 1)}>
            {count}
        </button>
    );
};

The behavior is:

Initial render
count = 0
    ↓
Effect runs

count = 1
    ↓
Effect runs

count = 2
    ↓
Effect runs

count = 3
    ↓
Effect runs

But if another state changes:

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

and the effect is:

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

Changing name does not cause this effect to run again.

count changes → Effect runs
name changes  → Effect doesn't run

Multiple Dependencies

You can specify multiple dependencies:

useEffect(() => {
    console.log("User or category changed");
}, [userId, categoryId]);

The effect runs when either dependency changes.

For example:

const [userId, setUserId] = useState(1);
const [categoryId, setCategoryId] = useState(10);

useEffect(() => {
    console.log("Reload data");
}, [userId, categoryId]);

Behavior:

userId changes
    ↓
Effect runs

categoryId changes
    ↓
Effect runs

both change
    ↓
Effect runs

The important concept is:

The effect is re-executed when at least one dependency has changed since the previous render.


How React Determines Whether a Dependency Changed

React compares dependency values between renders.

For primitive values:

[count]

comparison is straightforward.

For example:

Previous: 10
Current: 10
→ unchanged

Previous: 10
Current: 11
→ changed

For objects and arrays, the situation is different because React compares references.

const user = {
    id: 1,
    name: "Syed"
};

Creating another object:

const user2 = {
    id: 1,
    name: "Syed"
};

does not make them the same reference:

user === user2
→ false

Therefore:

useEffect(() => {
    console.log("User changed");
}, [user]);

can run when a new object reference is created, even if the object's properties contain the same values.

This becomes very important when working with objects, arrays, and functions.


Using useEffect for API Calls

One of the most common uses of useEffect is fetching data.

Example:

import { useEffect, useState } from "react";

type User = {
    id: number;
    name: string;
};

const Users = () => {

    const [users, setUsers] = useState<User[]>([]);

    useEffect(() => {

        fetch("https://api.example.com/users")
            .then(response => response.json())
            .then(data => {
                setUsers(data);
            });

    }, []);

    return (
        <div>
            {users.map(user => (
                <div key={user.id}>
                    {user.name}
                </div>
            ))}
        </div>
    );
};

The overall functionality is:

Component starts
       ↓
users = []
       ↓
Initial UI rendered
       ↓
useEffect runs
       ↓
API request sent
       ↓
Server responds
       ↓
setUsers(data)
       ↓
State changes
       ↓
Component renders again
       ↓
Users displayed

The empty dependency array means the API request is not intentionally repeated every time the component renders.


API Call Based on a Dependency

Suppose the API depends on a user ID.

const UserDetails = ({ userId }: { userId: number }) => {

    const [user, setUser] = useState<any>(null);

    useEffect(() => {

        fetch(`https://api.example.com/users/${userId}`)
            .then(response => response.json())
            .then(data => {
                setUser(data);
            });

    }, [userId]);

    return (
        <div>
            {user && <h2>{user.name}</h2>}
        </div>
    );
};

Now the behavior is:

userId = 10
    ↓
Fetch /users/10

userId = 20
    ↓
Fetch /users/20

userId = 30
    ↓
Fetch /users/30

This is an important pattern:

Use the dependency array when the external operation depends on changing component data.


Handling Loading, Error and Data Together

A realistic API effect usually manages more than just the data.

const Users = () => {

    const [users, setUsers] = useState<User[]>([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState("");

    useEffect(() => {

        const loadUsers = async () => {

            try {
                setLoading(true);

                const response = await fetch(
                    "https://api.example.com/users"
                );

                if (!response.ok) {
                    throw new Error("Failed to load users");
                }

                const data = await response.json();

                setUsers(data);
            }
            catch (error) {
                setError("Unable to load users");
            }
            finally {
                setLoading(false);
            }
        };

        loadUsers();

    }, []);

    if (loading) {
        return <p>Loading...</p>;
    }

    if (error) {
        return <p>{error}</p>;
    }

    return (
        <div>
            {users.map(user => (
                <div key={user.id}>
                    {user.name}
                </div>
            ))}
        </div>
    );
};

The important idea is that useEffect coordinates the external API operation with React state.


useEffect and async

You should not make the effect callback itself directly async like this:

useEffect(async () => {
    // ...
}, []);

The reason is that an effect callback is expected to optionally return a cleanup function, whereas an async function returns a Promise.

Instead:

useEffect(() => {

    const loadData = async () => {
        const response = await fetch("/api/users");
        const data = await response.json();

        setUsers(data);
    };

    loadData();

}, []);

This keeps the effect callback synchronous while the asynchronous operation happens inside it.


Cleanup Function

One of the most important features of useEffect is cleanup.

Example:

useEffect(() => {

    const timer = setInterval(() => {
        console.log("Running...");
    }, 1000);

    return () => {
        clearInterval(timer);
    };

}, []);

The effect creates something:

setInterval()

The cleanup removes it:

clearInterval()

Think of it as:

Effect:
"Start something"

Cleanup:
"Stop what you started"

This prevents resources from continuing to run after they are no longer needed.


Why Cleanup Is Important

Suppose you create a timer:

useEffect(() => {

    setInterval(() => {
        console.log("Hello");
    }, 1000);

}, []);

There is no cleanup.

If the component is removed, the interval may continue running.

A better version is:

useEffect(() => {

    const timerId = setInterval(() => {
        console.log("Hello");
    }, 1000);

    return () => {
        clearInterval(timerId);
    };

}, []);

Now React has a way to clean up the resource.


Cleanup When a Component Is Unmounted

Consider:

const Timer = () => {

    useEffect(() => {

        const id = setInterval(() => {
            console.log("Tick");
        }, 1000);

        return () => {
            clearInterval(id);
        };

    }, []);

    return <h2>Timer</h2>;
};

If the component is removed:

{showTimer && <Timer />}

and:

setShowTimer(false);

React removes Timer.

Before the effect is permanently discarded, React runs its cleanup:

Timer component removed
        ↓
Cleanup executes
        ↓
clearInterval()
        ↓
Timer stopped

Cleanup Before an Effect Runs Again

Cleanup is not only for unmounting.

Consider:

useEffect(() => {

    console.log(`Listening for ${userId}`);

    return () => {
        console.log(`Stop listening for ${userId}`);
    };

}, [userId]);

If:

userId = 10

then:

Effect starts listening for 10

When userId changes to 20:

Cleanup for 10
       ↓
Effect for 20

So the lifecycle is conceptually:

Previous effect
      ↓
Cleanup previous effect
      ↓
New effect

This is extremely useful for subscriptions.


useEffect With window Events

Suppose you want to listen for browser resize events.

const WindowSize = () => {

    const [width, setWidth] = useState(window.innerWidth);

    useEffect(() => {

        const handleResize = () => {
            setWidth(window.innerWidth);
        };

        window.addEventListener("resize", handleResize);

        return () => {
            window.removeEventListener("resize", handleResize);
        };

    }, []);

    return <h2>Width: {width}px</h2>;
};

The effect performs two operations:

Mount
  ↓
Add resize listener

Cleanup:

Unmount
  ↓
Remove resize listener

This is a classic useEffect pattern.


Event Listener Based on a Dependency

You can also recreate a subscription when some value changes.

useEffect(() => {

    const handleMessage = (event: MessageEvent) => {
        console.log(`User ${userId} received message`);
    };

    window.addEventListener("message", handleMessage);

    return () => {
        window.removeEventListener("message", handleMessage);
    };

}, [userId]);

When userId changes:

Remove old listener
        ↓
Create new listener
        ↓
New listener uses latest userId

This prevents old subscriptions from remaining active.


useEffect for document.title

A very simple example:

const Counter = () => {

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

    useEffect(() => {
        document.title = `Count: ${count}`;
    }, [count]);

    return (
        <button onClick={() => setCount(count + 1)}>
            Count: {count}
        </button>
    );
};

The browser tab title becomes:

Count: 0
Count: 1
Count: 2
Count: 3

This is a good example of synchronizing React state with an external browser API.


useEffect for localStorage

Suppose you want to save a theme preference.

const Theme = () => {

    const [theme, setTheme] = useState("light");

    useEffect(() => {
        localStorage.setItem("theme", theme);
    }, [theme]);

    return (
        <button
            onClick={() =>
                setTheme(theme === "light" ? "dark" : "light")
            }
        >
            {theme}
        </button>
    );
};

Every time theme changes:

theme changes
     ↓
React renders
     ↓
Effect executes
     ↓
localStorage updated

Reading From localStorage

You can also initialize state from browser storage.

const Theme = () => {

    const [theme, setTheme] = useState("light");

    useEffect(() => {

        const savedTheme = localStorage.getItem("theme");

        if (savedTheme) {
            setTheme(savedTheme);
        }

    }, []);

    return <h2>{theme}</h2>;
};

Here the effect reads external data after mounting.

However, for synchronous browser APIs such as localStorage, you can often initialize state directly instead:

const [theme, setTheme] = useState(() => {
    return localStorage.getItem("theme") ?? "light";
});

This avoids an initial render with the wrong value.


useEffect With Props

Effects can depend on props.

const Product = ({ productId }: { productId: number }) => {

    useEffect(() => {
        console.log(`Loading product ${productId}`);
    }, [productId]);

    return <h2>Product {productId}</h2>;
};

If the parent changes:

<Product productId={10} />

to:

<Product productId={20} />

then the effect runs again.

The pattern is:

Parent changes prop
        ↓
Child renders with new prop
        ↓
Dependency changed
        ↓
Effect executes

useEffect for Authentication

For example, you might need to verify a stored authentication token.

useEffect(() => {

    const token = localStorage.getItem("token");

    if (token) {
        validateToken(token);
    }

}, []);

The effect can coordinate authentication with an external API or storage mechanism.

However, authentication logic should generally be organized into reusable services/hooks rather than putting all authentication behavior into one component.


useEffect for WebSocket Connections

WebSockets are a classic example because they require both setup and cleanup.

useEffect(() => {

    const socket = new WebSocket(
        "wss://example.com/socket"
    );

    socket.onmessage = event => {
        console.log(event.data);
    };

    return () => {
        socket.close();
    };

}, []);

The lifecycle is:

Component mounted
       ↓
WebSocket created
       ↓
Messages received
       ↓
Component removed
       ↓
Cleanup
       ↓
WebSocket closed

Without cleanup, the connection can remain open unnecessarily.


WebSocket Based on a User ID

useEffect(() => {

    const socket = new WebSocket(
        `wss://example.com/users/${userId}`
    );

    socket.onmessage = event => {
        console.log(event.data);
    };

    return () => {
        socket.close();
    };

}, [userId]);

Now changing userId causes:

Old WebSocket
      ↓
close()
      ↓
New WebSocket
      ↓
Connect using new userId

This is one of the strongest examples of why cleanup and dependencies work together.


useEffect for Subscriptions

Suppose an external library exposes:

subscribe(callback)
unsubscribe(callback)

You can integrate it with React:

useEffect(() => {

    const handleUpdate = (data: any) => {
        console.log(data);
    };

    subscribe(handleUpdate);

    return () => {
        unsubscribe(handleUpdate);
    };

}, []);

The general pattern is:

Subscribe
   ↓
Use external data
   ↓
Cleanup
   ↓
Unsubscribe

useEffect With AbortController

When fetching data, cancellation can be important.

useEffect(() => {

    const controller = new AbortController();

    fetch("/api/users", {
        signal: controller.signal
    })
        .then(response => response.json())
        .then(data => {
            setUsers(data);
        })
        .catch(error => {
            if (error.name !== "AbortError") {
                console.error(error);
            }
        });

    return () => {
        controller.abort();
    };

}, []);

The overall behavior is:

Effect starts
     ↓
Create AbortController
     ↓
API request starts
     ↓
Component removed / effect replaced
     ↓
Cleanup executes
     ↓
Request aborted

This is particularly useful when effects perform network requests that may become irrelevant.


Avoiding Race Conditions in API Requests

Suppose userId changes quickly:

Request A → userId = 10
Request B → userId = 20

If request A takes longer than request B, response A might arrive after response B.

That can result in stale data being displayed.

Using cancellation can help:

useEffect(() => {

    const controller = new AbortController();

    const loadUser = async () => {

        try {

            const response = await fetch(
                `/api/users/${userId}`,
                {
                    signal: controller.signal
                }
            );

            const data = await response.json();

            setUser(data);

        }
        catch (error: any) {

            if (error.name !== "AbortError") {
                console.error(error);
            }

        }
    };

    loadUser();

    return () => {
        controller.abort();
    };

}, [userId]);

When userId changes:

Cancel request for old user
        ↓
Start request for new user

This makes the effect's lifecycle much safer.


useEffect and Derived Data

A common mistake is using useEffect for calculations that can be performed during rendering.

For example:

const [firstName, setFirstName] = useState("Syed");
const [lastName, setLastName] = useState("Ahmed");

const [fullName, setFullName] = useState("");

useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

This works, but it is usually unnecessary.

fullName is derived from existing state.

You can simply do:

const fullName = `${firstName} ${lastName}`;

Then:

return <h2>{fullName}</h2>;

This is an important rule:

Don't use useEffect when ordinary rendering logic can calculate the value.


Bad Use of useEffect

Consider:

useEffect(() => {
    setFullName(firstName + " " + lastName);
}, [firstName, lastName]);

The sequence becomes:

firstName changes
      ↓
Render
      ↓
Effect
      ↓
setFullName()
      ↓
Another render

You have introduced an unnecessary render.

Instead:

const fullName = `${firstName} ${lastName}`;

No effect is required.


useEffect Should Not Be Used for Everything

A useful question before using useEffect is:

"Am I synchronizing React with something outside React?"

If the answer is yes, useEffect may be appropriate.

If the answer is no, you may not need it.

For example:

const fullName = `${firstName} ${lastName}`;

No effect.

Filtering:

const activeUsers = users.filter(user => user.active);

No effect.

Mapping:

const names = users.map(user => user.name);

No effect.

Calculating:

const total = price * quantity;

No effect.

But:

document.title = ...

Effect may be appropriate.

fetch(...)

Effect may be appropriate.

window.addEventListener(...)

Effect may be appropriate.

WebSocket connection

Effect may be appropriate.


useEffect and Infinite Loops

One of the most common mistakes is creating an effect that updates a dependency that causes the effect to execute again.

For example:

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

useEffect(() => {
    setCount(count + 1);
}, [count]);

The sequence is:

count = 0
    ↓
Effect runs
    ↓
setCount(1)
    ↓
Render
    ↓
count changed
    ↓
Effect runs
    ↓
setCount(2)
    ↓
Render
    ↓
count changed
    ↓
Effect runs
    ↓
...

This can continue indefinitely.

The key problem is:

Effect depends on count
        ↓
Effect changes count
        ↓
Dependency changes
        ↓
Effect runs again

You need to carefully consider whether the state update is actually necessary.


Another Infinite Loop Example

This can also happen when an effect updates state that is recreated on every render.

For example:

const [users, setUsers] = useState<User[]>([]);

const filteredUsers = users.filter(user => user.active);

useEffect(() => {
    setFilteredUsers(filteredUsers);
}, [filteredUsers]);

filteredUsers is a new array on every render.

Therefore:

Render
 ↓
filter() creates new array
 ↓
Dependency reference changed
 ↓
Effect runs
 ↓
setState
 ↓
Render
 ↓
filter() creates another array
 ↓
Dependency reference changed
 ↓
Effect runs
...

Often the correct solution is to derive the value directly:

const filteredUsers = users.filter(user => user.active);

rather than storing it separately.


Functions as Dependencies

Consider:

const loadUsers = () => {
    // load users
};

useEffect(() => {
    loadUsers();
}, [loadUsers]);

This can be problematic because functions declared inside a component can receive a new reference on each render.

Conceptually:

Render 1 → loadUsers reference A
Render 2 → loadUsers reference B
Render 3 → loadUsers reference C

React sees different function references.

For this kind of situation, you may restructure the effect:

useEffect(() => {

    const loadUsers = async () => {
        // API call
    };

    loadUsers();

}, []);

Or, when appropriate, use useCallback to stabilize a function reference.


useEffect With useCallback

Example:

const loadUser = useCallback(() => {
    console.log(userId);
}, [userId]);

useEffect(() => {
    loadUser();
}, [loadUser]);

Here useCallback controls the function reference.

The relationship is:

userId changes
      ↓
useCallback creates new function
      ↓
loadUser reference changes
      ↓
useEffect dependency changes
      ↓
Effect runs

This is useful when a function itself needs to be treated as a dependency.

But don't use useCallback automatically. It should solve a real dependency/reference problem.


useEffect With Object Dependencies

Consider:

const user = {
    id: 10,
    name: "Syed"
};

useEffect(() => {
    console.log(user);
}, [user]);

The object is recreated during rendering.

Therefore the reference may change:

Render 1 → user object A
Render 2 → user object B
Render 3 → user object C

Even though:

id = 10
name = "Syed"

remain the same.

If the effect only depends on the ID, use:

useEffect(() => {
    console.log(user.id);
}, [user.id]);

This expresses the actual dependency more precisely.


useEffect for Conditional Logic

You can conditionally execute logic inside the effect.

For example:

useEffect(() => {

    if (!userId) {
        return;
    }

    loadUser(userId);

}, [userId]);

The effect itself is still called on every relevant dependency change, but the operation only happens when the condition is satisfied.

This is preferable to conditionally calling the Hook.


Do Not Call useEffect Inside an if

Incorrect:

if (userId) {
    useEffect(() => {
        loadUser(userId);
    }, [userId]);
}

Hooks must be called at the top level of the component.

Correct:

useEffect(() => {

    if (userId) {
        loadUser(userId);
    }

}, [userId]);

The rule is:

Call Hooks consistently at the top level. Put conditional logic inside the effect.


Multiple useEffect Hooks

A component can have multiple effects.

useEffect(() => {
    document.title = `Count: ${count}`;
}, [count]);

useEffect(() => {
    localStorage.setItem("theme", theme);
}, [theme]);

This is often better than creating one giant effect.

Each effect should ideally represent one logical synchronization process.

For example:

Effect 1 → Browser title
Effect 2 → Local storage
Effect 3 → WebSocket
Effect 4 → API subscription

This makes the component easier to understand and maintain.


Multiple Effects With Different Dependencies

useEffect(() => {
    console.log("User changed");
}, [userId]);

useEffect(() => {
    console.log("Theme changed");
}, [theme]);

useEffect(() => {
    console.log("Count changed");
}, [count]);

Now each effect responds to its own dependency.

userId changes → Effect 1

theme changes → Effect 2

count changes → Effect 3

Changing count doesn't cause Effect 1 or Effect 2 to execute merely because those effects exist.


Effect Dependency Array and Initial Execution

An effect with dependencies runs on the initial render as well.

For example:

useEffect(() => {
    console.log(userId);
}, [userId]);

It doesn't mean:

"Run only when userId changes after the first render."

Instead, think:

Initial render
    ↓
Effect runs

Later render
    ↓
Compare userId

Changed?
   ↓
Yes → Effect runs
No  → Effect doesn't run

Effect Cleanup Order

Suppose:

useEffect(() => {

    console.log("Effect started");

    return () => {
        console.log("Cleanup");
    };

}, [value]);

When value changes:

Previous effect
       ↓
Cleanup previous effect
       ↓
New effect

So conceptually:

value = 1
   ↓
Effect for 1

value = 2
   ↓
Cleanup for 1
   ↓
Effect for 2

value = 3
   ↓
Cleanup for 2
   ↓
Effect for 3

This is essential for subscriptions, timers, event listeners and connections.


useEffect With Timers

setTimeout

useEffect(() => {

    const timer = setTimeout(() => {
        console.log("Executed after 3 seconds");
    }, 3000);

    return () => {
        clearTimeout(timer);
    };

}, []);

The cleanup prevents the timeout from firing if the component is removed before the timeout completes.


setInterval

useEffect(() => {

    const timer = setInterval(() => {
        console.log("Executing every second");
    }, 1000);

    return () => {
        clearInterval(timer);
    };

}, []);

The difference is:

setTimeout
→ Executes once

setInterval
→ Executes repeatedly

Both should generally be cleaned up when they are no longer needed.


Timer Depending on State

useEffect(() => {

    const timer = setInterval(() => {
        console.log(count);
    }, 1000);

    return () => {
        clearInterval(timer);
    };

}, [count]);

Whenever count changes:

Old interval cleared
      ↓
New interval created
      ↓
New interval sees current count

Whether recreating the interval is desirable depends on the use case.

For continuously running timers that need the latest state, there are sometimes better patterns involving functional state updates or refs.


Functional State Update With Effects

Suppose you want a counter that increases every second.

A straightforward approach is:

useEffect(() => {

    const timer = setInterval(() => {
        setCount(count + 1);
    }, 1000);

    return () => {
        clearInterval(timer);
    };

}, [count]);

This works, but the interval is recreated every time count changes.

A better pattern is:

useEffect(() => {

    const timer = setInterval(() => {
        setCount(previousCount => previousCount + 1);
    }, 1000);

    return () => {
        clearInterval(timer);
    };

}, []);

Now the interval doesn't need count as a dependency because React provides the latest state through:

previousCount => previousCount + 1

This is an important connection between useEffect and functional state updates.


useEffect for DOM Interaction

Sometimes you need to interact with a DOM element.

For example:

const Input = () => {

    const inputRef = useRef<HTMLInputElement>(null);

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

    return <input ref={inputRef} />;
};

The sequence is:

Component renders
      ↓
Input DOM element created
      ↓
Ref points to element
      ↓
Effect runs
      ↓
focus()

This is useful for integrating React with imperative browser APIs.


useEffect With Third-Party Libraries

Suppose a third-party chart library requires a DOM element.

Conceptually:

const Chart = () => {

    const chartRef = useRef<HTMLDivElement>(null);

    useEffect(() => {

        const chart = createChart(chartRef.current);

        return () => {
            chart.destroy();
        };

    }, []);

    return <div ref={chartRef} />;
};

React manages the component lifecycle while the third-party library manages the chart.

The effect becomes the bridge:

React component
      ↓
useEffect
      ↓
Third-party library
      ↓
DOM / external system

Cleanup then disconnects the external system.


useEffect and Previous Values

useEffect itself does not directly provide the previous value.

You can combine it with useRef.

const [count, setCount] = useState(0);
const previousCount = useRef(count);

useEffect(() => {

    console.log(
        `Previous: ${previousCount.current}`
    );

    console.log(
        `Current: ${count}`
    );

    previousCount.current = count;

}, [count]);

Conceptually:

count = 0
previousCount = 0

count changes to 1
        ↓
Effect
        ↓
previous = 0
current = 1
        ↓
Store 1 as previous

This is useful when an effect needs to compare current and previous values.


useEffect for Authentication Redirects

For example:

useEffect(() => {

    if (!isAuthenticated) {
        navigate("/login");
    }

}, [isAuthenticated, navigate]);

The effect reacts to authentication state.

isAuthenticated = true
       ↓
No redirect

isAuthenticated = false
       ↓
Navigate to /login

This is another example of synchronizing application state with an external system such as routing.


useEffect for Search

Suppose you have a search box:

const [search, setSearch] = useState("");
const [results, setResults] = useState<User[]>([]);

You could fetch results whenever the search value changes:

useEffect(() => {

    if (!search.trim()) {
        setResults([]);
        return;
    }

    fetch(`/api/users?search=${search}`)
        .then(response => response.json())
        .then(data => {
            setResults(data);
        });

}, [search]);

The lifecycle is:

User types "s"
    ↓
search changes
    ↓
Effect runs
    ↓
API request

User types "y"
    ↓
search changes
    ↓
Effect runs
    ↓
API request

For production search boxes, you would often add debouncing so that an API request isn't sent for every keystroke.


Debouncing With useEffect

useEffect(() => {

    const timer = setTimeout(() => {

        if (search.trim()) {
            fetch(`/api/users?search=${search}`);
        }

    }, 500);

    return () => {
        clearTimeout(timer);
    };

}, [search]);

Now:

User types
    ↓
Timer starts

User types again
    ↓
Previous timer cancelled
    ↓
New timer starts

User stops typing
    ↓
500ms passes
    ↓
API request

This is a very practical use of effect cleanup.


useEffect and Stale Closures

This is an advanced but important topic.

Consider:

useEffect(() => {

    const timer = setInterval(() => {
        console.log(count);
    }, 1000);

    return () => {
        clearInterval(timer);
    };

}, []);

The effect has an empty dependency array.

The callback may capture the value of count from the render in which the effect was created.

This is called a stale closure problem.

For state updates, a functional updater can often solve the issue:

useEffect(() => {

    const timer = setInterval(() => {
        setCount(previous => previous + 1);
    }, 1000);

    return () => {
        clearInterval(timer);
    };

}, []);

The functional updater receives the current state rather than relying on a potentially stale captured value.


useEffect Does Not Make a Component "Async"

A component remains a normal React component.

For example:

const Users = () => {

    useEffect(() => {
        fetch("/api/users");
    }, []);

    return <h2>Users</h2>;
};

The component doesn't pause rendering until the API finishes.

Instead:

Render UI
   ↓
Effect starts
   ↓
API runs asynchronously
   ↓
Response arrives later
   ↓
State updated
   ↓
Render again

This distinction is important.


useEffect and State Update Cycle

Consider:

const [users, setUsers] = useState<User[]>([]);

useEffect(() => {
    fetchUsers().then(data => {
        setUsers(data);
    });
}, []);

There are two separate operations:

First render

users = []
↓
Render loading/empty UI
↓
Effect runs

After API response

API returns data
↓
setUsers(data)
↓
State changes
↓
Component renders again
↓
users contains API data

The effect itself doesn't directly "render the data."

It changes state, and state causes React to render the new UI.


useEffect and Dependency Mistakes

Suppose:

const [userId, setUserId] = useState(10);

useEffect(() => {
    fetch(`/api/users/${userId}`);
}, []);

The effect uses userId, but the dependency array is empty.

This means the effect won't react when userId changes.

Usually the dependency should reflect the value used by the effect:

useEffect(() => {
    fetch(`/api/users/${userId}`);
}, [userId]);

The general principle is:

Dependencies should describe the reactive values that the effect reads and whose changes should cause synchronization again.

Modern React tooling can help identify missing dependencies.


useEffect and eslint

React projects commonly use the react-hooks/exhaustive-deps lint rule.

For example:

useEffect(() => {
    console.log(userId);
}, []);

A linter may warn that userId is missing from the dependency array.

Instead:

useEffect(() => {
    console.log(userId);
}, [userId]);

Don't treat dependency warnings as something to silence blindly.

They often indicate that the effect's synchronization logic needs reconsideration.


useEffect With No Cleanup

Not every effect needs cleanup.

For example:

useEffect(() => {
    document.title = `Count: ${count}`;
}, [count]);

There is nothing persistent to clean up.

So:

useEffect(() => {
    // Setup external state
}, [count]);

may be enough.

Cleanup is required when the effect creates or registers something that needs to be removed.

Examples:

setInterval       → clearInterval
setTimeout        → clearTimeout
addEventListener  → removeEventListener
WebSocket         → close
subscribe         → unsubscribe
Third-party chart → destroy

A Practical Classification of useEffect

Most real-world effects fall into a few categories.

Category Example
API/network fetch()
Browser API document.title
Events window.addEventListener()
Timer setInterval()
Subscription subscribe()
WebSocket new WebSocket()
Storage localStorage
Third-party library Chart/editor/map
Synchronization React state → external system
Cleanup Remove listeners/connections

This makes it easier to recognize when useEffect is appropriate.


A Complete Real-World Example

Consider a user search page.

import {
    useEffect,
    useState
} from "react";

type User = {
    id: number;
    name: string;
};

const UserSearch = () => {

    const [search, setSearch] = useState("");
    const [users, setUsers] = useState<User[]>([]);
    const [loading, setLoading] = useState(false);

    useEffect(() => {

        if (!search.trim()) {
            setUsers([]);
            return;
        }

        const controller = new AbortController();

        const timer = setTimeout(async () => {

            try {

                setLoading(true);

                const response = await fetch(
                    `/api/users?search=${encodeURIComponent(search)}`,
                    {
                        signal: controller.signal
                    }
                );

                const data = await response.json();

                setUsers(data);

            }
            catch (error: any) {

                if (error.name !== "AbortError") {
                    console.error(error);
                }

            }
            finally {
                setLoading(false);
            }

        }, 500);

        return () => {
            clearTimeout(timer);
            controller.abort();
        };

    }, [search]);

    return (
        <div>

            <input
                value={search}
                onChange={e => setSearch(e.target.value)}
                placeholder="Search users"
            />

            {loading && <p>Loading...</p>}

            {users.map(user => (
                <div key={user.id}>
                    {user.name}
                </div>
            ))}

        </div>
    );
};

This single example demonstrates several important useEffect concepts:

search changes
      ↓
Effect executes
      ↓
500ms debounce timer
      ↓
Previous timer cleaned up if search changes
      ↓
API request starts
      ↓
AbortController tracks request
      ↓
New search
      ↓
Old request cancelled
      ↓
New request starts
      ↓
API response
      ↓
setUsers()
      ↓
Component renders results

This is much closer to how useEffect appears in real applications.


Common useEffect Mistakes

Mistake 1 — Using it for calculations

Avoid:

useEffect(() => {
    setTotal(price * quantity);
}, [price, quantity]);

Prefer:

const total = price * quantity;

Mistake 2 — Missing dependencies

Avoid:

useEffect(() => {
    loadUser(userId);
}, []);

when the effect is supposed to respond to changing userId.

Prefer:

useEffect(() => {
    loadUser(userId);
}, [userId]);

Mistake 3 — Forgetting cleanup

Avoid:

useEffect(() => {
    window.addEventListener("resize", handleResize);
}, []);

Prefer:

useEffect(() => {

    window.addEventListener("resize", handleResize);

    return () => {
        window.removeEventListener("resize", handleResize);
    };

}, []);

Mistake 4 — Updating a dependency inside the effect unnecessarily

Avoid patterns like:

useEffect(() => {
    setCount(count + 1);
}, [count]);

because they can create an infinite update cycle.


Mistake 5 — Making the effect callback async

Avoid:

useEffect(async () => {
    await loadData();
}, []);

Prefer:

useEffect(() => {

    const loadData = async () => {
        await fetchData();
    };

    loadData();

}, []);

Mistake 6 — Calling Hooks conditionally

Avoid:

if (userId) {
    useEffect(() => {
        // ...
    }, []);
}

Prefer:

useEffect(() => {

    if (userId) {
        // ...
    }

}, [userId]);

Mistake 7 — Creating unnecessary derived state

Avoid:

const [activeUsers, setActiveUsers] = useState<User[]>([]);

useEffect(() => {
    setActiveUsers(
        users.filter(user => user.active)
    );
}, [users]);

Prefer:

const activeUsers = users.filter(user => user.active);

If the calculation is expensive, consider useMemo rather than automatically reaching for useEffect.


useEffect vs useState

These Hooks solve completely different problems.

useState

Stores component data:

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

Think:

useState
    ↓
"What data should React remember?"

useEffect

Synchronizes with external systems:

useEffect(() => {
    document.title = `Count: ${count}`;
}, [count]);

Think:

useEffect
    ↓
"What external operation should happen because
the component has rendered or some reactive value changed?"

useEffect vs useMemo

These are also very different.

useMemo

Caches a calculated value:

const activeUsers = useMemo(
    () => users.filter(user => user.active),
    [users]
);

useEffect

Performs a side effect:

useEffect(() => {
    saveUsersToServer(users);
}, [users]);

Think:

useMemo
→ Calculate and remember a value

useEffect
→ Synchronize with an external system

useEffect vs useCallback

useCallback remembers a function reference:

const loadUser = useCallback(() => {
    // ...
}, [userId]);

useEffect performs an effect:

useEffect(() => {
    loadUser();
}, [loadUser]);

Think:

useCallback
→ Stabilize function reference

useEffect
→ Run synchronization logic

useEffect vs Event Handler

This distinction is very important.

Suppose clicking a button saves data:

const handleSave = () => {
    saveUser();
};

You generally don't need:

useEffect(() => {
    saveUser();
}, [isSaveClicked]);

An event handler is appropriate because the action is caused directly by the user's click.

Use an effect when something needs to synchronize because of rendering/state/props changes.

A useful distinction is:

User interaction
    ↓
Event handler

versus:

Render/state/prop change
    ↓
useEffect

The Mental Model You Should Remember

The simplest way to understand useEffect is:

useEffect is React's mechanism for synchronizing a component with something outside React.

For example:

React State
    ↓
useEffect
    ↓
Browser API

or:

React State
    ↓
useEffect
    ↓
API Server

or:

React Component
    ↓
useEffect
    ↓
WebSocket

or:

React Component
    ↓
useEffect
    ↓
Event Listener

The dependency array determines when React needs to synchronize again, and the cleanup function determines how to stop the previous synchronization.


The Three Most Important Forms

Keep these three forms firmly in mind:

Runs after every render

useEffect(() => {
    // Runs after every render
});

Runs after initial mount

useEffect(() => {
    // Runs after initial mount
}, []);

Runs after initial mount and when dependencies change

useEffect(() => {
    // Runs initially and when count changes
}, [count]);

And when cleanup is required:

useEffect(() => {

    // Start / synchronize something

    return () => {
        // Stop / clean up something
    };

}, [dependency]);

useEffect Complete Lifecycle

A good overall mental model is:

                 React Component
                       │
                       ▼
                    Render
                       │
                       ▼
                DOM is updated
                       │
                       ▼
                 Effect executes
                       │
          ┌────────────┴────────────┐
          │                         │
       External                 No external
       operation                  operation
          │
          ▼
     API / Timer /
     Event / DOM /
     WebSocket /
     Storage /
     Subscription
          │
          ▼
     State may change
          │
          ▼
      Re-render
          │
          ▼
 Dependency comparison
          │
          ├── No change → Effect doesn't rerun
          │
          └── Changed
                 │
                 ▼
          Cleanup previous effect
                 │
                 ▼
          Run new effect

Final Rule: When Should You Use useEffect?

Before writing useEffect, ask:

Does this code need to interact with
something outside React?
        │
        ├── No
        │     ↓
        │   Probably don't use useEffect
        │
        └── Yes
              ↓
         useEffect may be appropriate
              ↓
       What does it depend on?
              ↓
       Add those dependencies
              ↓
       Does it create a subscription,
       timer, listener, connection, etc.?
              ↓
            Yes
              ↓
       Return a cleanup function

The most important principle is not:

"useEffect is where I put code that should run after rendering."

A better and more accurate mental model is:

"I use useEffect when my React component needs to synchronize with an external system, and the dependency array tells React when that synchronization needs to be repeated."

 

← Back to Articles