Skip to main content

Command Palette

Search for a command to run...

React useEffect – Side Effects & Component Lifecycle

Updated
12 min readView as Markdown

After learning Lifting State Up on Day 68, we now understand how components can share and coordinate state.

But there is another important question:

What happens when our component needs to do something outside of rendering UI?

For example:

  • Fetch data from an API

  • Start a timer

  • Listen for browser events

  • Update the document title

  • Subscribe to something

  • Read or interact with browser APIs

  • Perform an action when state changes

These are called side effects, and React provides the useEffect Hook to handle them.


🔹 What Is a Side Effect?

A side effect is an operation that happens outside the normal process of calculating and rendering JSX.

For example:

function App() {
  document.title = "My React App";

  return <h1>Hello React</h1>;
}

This changes something outside the React component itself.

That's a side effect.

Other examples:

API requests
Timers
Console logging
DOM manipulation
Event listeners
Subscriptions
Browser storage
WebSocket connections

React rendering should ideally remain pure.

That means:

Props + State
     ↓
   Render
     ↓
    JSX

Side effects happen separately:

Render
  ↓
React commits UI
  ↓
useEffect runs
  ↓
External operation

🔹 What Is useEffect?

useEffect is a React Hook used to synchronize a component with an external system.

Basic syntax:

import { useEffect } from "react";

useEffect(() => {
  // Side effect
});

For example:

import { useEffect } from "react";

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

  return <h1>Hello React</h1>;
}

The effect runs after React renders the component.


🔹 The Dependency Array

The dependency array determines when an effect should run.

There are three important patterns.


1️⃣ No Dependency Array

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

This runs after every render.

Example:

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

  useEffect(() => {
    console.log("Component rendered");
  });

  return (
    <>
      <h1>{count}</h1>

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

Every state update causes another render.

Therefore:

Initial render → Effect
count update   → Render → Effect
count update   → Render → Effect

2️⃣ Empty Dependency Array

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

The empty array means:

Run this effect when the component is mounted.

Example:

function App() {
  useEffect(() => {
    console.log("Component mounted");
  }, []);

  return <h1>Hello</h1>;
}

This is commonly used for things such as:

Initial API request
Initial setup
Browser event listeners
Initial subscriptions

3️⃣ Dependencies

You can provide values that the effect depends on.

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

Now the effect runs when count changes.

Example:

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

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

  return (
    <>
      <h1>{count}</h1>

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

The dependency array tells React:

"Run this effect again whenever one of these values changes."


🔹 Understanding the Dependency Array

Think of it like this:

Dependency Behavior
No array After every render
[] After initial mount
[count] When count changes
[count, name] When either changes

Example:

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

The effect depends on both count and name.


🔹 useEffect and State

One of the most common patterns is reacting to state changes.

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

useEffect(() => {
  console.log(`Hello ${name}`);
}, [name]);

Whenever name changes:

name changes
     ↓
React re-renders
     ↓
Effect checks dependencies
     ↓
name changed
     ↓
Effect runs

🔹 Updating the Browser Tab Title

A simple and useful example:

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

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

  return (
    <>
      <h1>{count}</h1>

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

Now the browser tab changes as the counter changes.

This is a perfect example of a side effect because:

React State
    ↓
useEffect
    ↓
Browser document title

🔹 Fetching Data With useEffect

One of the most common real-world use cases is fetching data.

Example:

import { useEffect, useState } from "react";

function Users() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((response) => response.json())
      .then((data) => {
        setUsers(data);
      });
  }, []);

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

The flow is:

Component mounts
       ↓
useEffect runs
       ↓
API request
       ↓
Data received
       ↓
setUsers()
       ↓
Component re-renders
       ↓
Users displayed

This pattern is extremely important for frontend development.


🔹 Loading State

Real applications need to show users what's happening.

function Users() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((response) => response.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      });
  }, []);

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

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

Now the UI has two states:

Loading
   ↓
API response
   ↓
Users displayed

🔹 Error Handling

A production application also needs an error state.

function Users() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/users")
      .then((response) => {
        if (!response.ok) {
          throw new Error("Failed to fetch users");
        }

        return response.json();
      })
      .then((data) => {
        setUsers(data);
      })
      .catch((error) => {
        setError(error.message);
      })
      .finally(() => {
        setLoading(false);
      });
  }, []);

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

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

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

Now we have:

Loading
   ↓
Success → Display data

OR

Error → Display error message

This is a common pattern in real React applications.


🔹 Cleanup Functions

Some effects create resources that need to be cleaned up.

Examples:

Timers
Event listeners
Subscriptions
WebSocket connections

useEffect allows us to return a cleanup function.

useEffect(() => {
  // Setup

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

🔹 Timer Example

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

  return () => {
    clearInterval(timer);
  };
}, []);

The effect creates a timer.

The cleanup removes it.

Component mounted
      ↓
Timer starts
      ↓
Component unmounts
      ↓
Cleanup runs
      ↓
Timer stops

Without cleanup, resources can continue running unnecessarily.


🔹 Event Listener Example

Suppose we want to listen for window resize events.

useEffect(() => {
  function handleResize() {
    console.log(window.innerWidth);
  }

  window.addEventListener("resize", handleResize);

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

Notice that we remove the same function we added.

This is important.


🔹 Component Lifecycle

Although modern React emphasizes effects as synchronization with external systems rather than lifecycle methods, it's useful to understand the traditional lifecycle idea.

A component goes through stages:

Mount
  ↓
Update
  ↓
Unmount

Mount

Component appears on the screen.

Update

State or props change.

Unmount

Component is removed from the UI.

useEffect can participate in these transitions through its setup and cleanup behavior.


🔹 Cleanup Before Re-running an Effect

This is an important concept.

Suppose:

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

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

When count changes, React conceptually does:

Previous effect
      ↓
Cleanup
      ↓
New effect

This becomes especially important for:

Subscriptions
Event listeners
Timers
Connections

🔹 useEffect With Props

Effects can depend on props as well.

function UserProfile({ userId }) {
  useEffect(() => {
    console.log("Fetching user:", userId);
  }, [userId]);

  return <h1>User Profile</h1>;
}

Whenever userId changes, the effect runs again.

This is useful when navigating between users:

/user/1
   ↓
Fetch user 1

/user/2
   ↓
Fetch user 2

🔹 Don't Use useEffect for Everything

This is one of the most important lessons.

You don't need an effect just because something changes.

For example, this is unnecessary:

const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [fullName, setFullName] = useState("");

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

Instead:

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

Why?

Because fullName is derived data.

It can be calculated directly during rendering.


🔹 Derived State vs Side Effect

This distinction is extremely important.

Derived value

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

No effect required.

External synchronization

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

Effect makes sense because we're interacting with something outside React.

Think:

If you can calculate it during render, don't automatically reach for useEffect.


🔹 Common Mistake: Infinite Loops

Consider:

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

What's happening?

count changes
   ↓
effect runs
   ↓
setCount()
   ↓
count changes
   ↓
effect runs
   ↓
setCount()
   ↓
...

This creates an infinite update loop.

Always ask:

"Does this effect update a dependency that causes the effect to run again?"


🔹 Another Common Mistake: Missing Dependencies

Example:

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

If the effect uses a reactive value such as user, but you intentionally leave it out of the dependency list, you can end up with stale values.

A better approach is generally:

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

Modern React tooling can help detect dependency mistakes.


🔹 useEffect and async

A common mistake is:

useEffect(async () => {
  // ❌ Don't do this
}, []);

Instead, define an async function inside the effect:

useEffect(() => {
  async function fetchUsers() {
    const response = await fetch(
      "https://jsonplaceholder.typicode.com/users"
    );

    const data = await response.json();

    setUsers(data);
  }

  fetchUsers();
}, []);

This keeps the effect callback's return value available for cleanup.


🔹 A Realistic Data Fetching Pattern

Here's a more complete example:

import { useEffect, useState } from "react";

function Users() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");

  useEffect(() => {
    async function fetchUsers() {
      try {
        setLoading(true);

        const response = await fetch(
          "https://jsonplaceholder.typicode.com/users"
        );

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

        const data = await response.json();

        setUsers(data);
      } catch (error) {
        setError(error.message);
      } finally {
        setLoading(false);
      }
    }

    fetchUsers();
  }, []);

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

  if (error) {
    return <h2>Error: {error}</h2>;
  }

  return (
    <div>
      <h1>Users</h1>

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

export default Users;

This pattern combines several concepts you've already learned:

useState
   +
useEffect
   +
API
   +
Conditional Rendering
   +
Lists
   +
Keys

That's exactly how React concepts begin coming together in real applications.


🔹 useEffect Mental Model

Instead of memorizing syntax, remember this:

React renders UI
      ↓
Something outside React needs synchronization
      ↓
useEffect
      ↓
Perform external operation
      ↓
Cleanup when necessary

Examples:

API request
Browser title
Timer
Event listener
Subscription
WebSocket
Storage interaction

🔹 Best Practices

✅ 1. Use effects for external synchronization

Don't use useEffect simply because you can.


✅ 2. Keep dependency arrays accurate

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

✅ 3. Clean up resources

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

✅ 4. Avoid unnecessary derived state

Instead of:

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

Prefer:

const total = price * quantity;

✅ 5. Handle API states

Real applications should consider:

Loading
Success
Error
Empty

✅ 6. Avoid infinite effect loops

Be careful when an effect updates one of its own dependencies.


✅ 7. Keep effects focused

One effect should ideally represent one synchronization concern.


🔹 useEffect Cheat Sheet

// Every render
useEffect(() => {
  // ...
});
// After initial mount
useEffect(() => {
  // ...
}, []);
// When count changes
useEffect(() => {
  // ...
}, [count]);
// With cleanup
useEffect(() => {
  const timer = setInterval(() => {
    console.log("Running");
  }, 1000);

  return () => {
    clearInterval(timer);
  };
}, []);

My Biggest Takeaway

Today I learned that useEffect isn't simply a "run some code after rendering" Hook.

Its real purpose is to synchronize a React component with something outside of React's rendering system.

The most important mental model for me is:

Render describes the UI. Effects synchronize the UI with external systems.

I also learned how dependency arrays control when effects run, why cleanup functions matter, how API requests fit into useEffect, and why unnecessary effects can make React applications harder to understand.

After learning state, events, forms, lists, conditional rendering, lifting state, and now effects, React is starting to feel much more like a complete application-building tool rather than just a UI library.

100 Days of Code: My Journey to Becoming a Full Stack Developer

Part 1 of 50

Welcome to my 100 Days of Code journey! In this series, I'll document my daily progress as I learn Full Stack Web Development from the ground up. Every post will cover what I learned, challenges I faced, mistakes I made, and the projects I built. My goal is not just to complete 100 days but to become a better developer through consistency, discipline, and learning in public. Topics I'll cover include: • Git & GitHub • HTML, CSS & JavaScript • React.js • Node.js & Express • MongoDB • APIs • Real-world Projects • AI tools for Developers Whether you're just starting out or revising your fundamentals, I hope this journey helps you learn alongside me. Let's build, learn, and grow together! 🚀