How to Use useEffect and useState Correctly in React

Última actualización: 02/12/2026
  • Understand how useState preserves and updates local component state, including functional updates and object handling.
  • Use useEffect for side effects with clear setup/cleanup logic and accurate dependency arrays to avoid leaks and loops.
  • Combine useState and useEffect for real-world tasks like data fetching, subscriptions and DOM updates in function components.
  • Follow the rules of hooks and treat effects as "after render" processes to keep React components predictable and maintainable.

React hooks useState and useEffect

React hooks completely changed how we write components, and mastering useState and useEffect is basically the entry ticket to writing modern React code. If you already use them but still get stuck with infinite loops, stale state, or confusing dependency arrays, this guide will help you connect all the missing pieces in a practical way.

In this article we’ll go deep into how to correctly use useState and useEffect together, why hooks were introduced in the first place, the official rules and caveats, how dependencies really work under the hood, common pitfalls that break your components, and battle‑tested patterns for side effects, cleanup and state management in real projects.

Why hooks, and why specifically useState and useEffect?

Hooks were added in React 16.8 to let function components use state and lifecycle features without classes. Before that, you had to write class components to keep local state, subscribe to external data, or react to lifecycle events like mounting and unmounting.

The big problem with classes was that related logic was often split across multiple lifecycle methods such as componentDidMount, componentDidUpdate and componentWillUnmount. You’d end up with bits of the same feature scattered around different methods based on when they run instead of what they do, which makes code harder to read, test and reuse.

Hooks flip this model around: with useState you attach state directly to a function component, and with useEffect you attach side effects directly to the logic that needs them. That way you can group everything related to a single concern in one place and easily extract reusable hooks later.

Among all hooks, useState and useEffect are the core primitives. You can build most everyday features just with these two: UI state like forms and toggles, network requests, subscriptions, timers, DOM updates and more. Other hooks (useRef, useReducer, useContext, useMemo…) are great, but they build on top of the same ideas.

Rules of React hooks you must never break

React hooks come with a couple of strict rules that make them work reliably across renders. If you violate them, you’ll either see runtime errors or very subtle, hard‑to‑debug bugs.

First rule: call hooks only inside React function components or custom hooks. You cannot use useState or useEffect in class components, regular utility functions, or outside of any component. A pattern like this is invalid:

import React, { Component, useState } from 'react';

class App extends Component {
  // ❌ This will throw - hooks don’t work in classes
  const  = useState(0);
  render() {
    return <h1>Hello, I am a Class Component!</h1>;
  }
}

The correct approach is to move to a function component if you want to use hooks:

import React, { useState } from 'react';

function App() {
  const  = useState('');

  return (
    <div>
      Your JSX code goes in here...
    </div>
  );
}

export default App;

Second rule: only call hooks at the top level of your component. That means no hooks inside loops, conditions, or nested functions. React relies on calling hooks in the same order on every render to “match up” each useState and useEffect call with its stored data, so this is invalid:

function BadComponent({ enabled }) {
  if (enabled) {
    // ❌ Wrong: hook inside a conditional
    const  = useState(0);
  }
  // ...
}

Instead, declare hooks unconditionally at the top and use conditionals inside the effect or JSX. The hook must always be called, but the logic it runs can be conditional:

function ConditionalEffectComponent() {
  const  = useState(false);

  useEffect(() => {
    if (isMounted) {
      console.log('Component mounted');
    }
  }, );

  return (
    <div>
      <button onClick={() => setIsMounted(!isMounted)}>
        {isMounted ? 'Unmount' : 'Mount'}
      </button>
    </div>
  );
}

The third implied rule is that hooks must be imported from React (or a hook library), not implemented ad‑hoc. This is obvious, but worth saying: the magic is in React’s internal hook dispatcher that tracks hook calls across renders.

Managing local state correctly with useState

useState lets you attach state to a function component and receive both the current value and an updater function. Conceptually it’s the functional counterpart of this.state and this.setState in class components.

A minimal counter example with useState looks like this:

import React, { useState } from 'react';

function Counter() {
  const  = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

export default Counter;

When you call useState(initialValue), React stores that state and returns a pair: the current state value and a setter. Unlike normal local variables, state survives across renders, so the count value doesn’t reset to 0 every time the component function runs.

You can use any serializable value for state: numbers, strings, booleans, arrays, objects and even functions. You can also call useState multiple times in the same component to keep related values separate instead of shoving everything into a single object.

When the new state value depends on the previous one, always use the functional update form. This avoids bugs when multiple state updates happen in quick succession:

setCount(prev => prev + 1);

Another subtle but important detail is that calling the setter replaces the entire state value, it doesn’t merge objects like this.setState in classes. If your state is an object or an array, you need to spread the previous value yourself:

const  = useState({ name: 'Alex', age: 30 });

// ✅ Correct: copy and update
setUser(prev => ({ ...prev, age: prev.age + 1 }));

For expensive initial values you can lazy‑initialize state by passing a function to useState. React will call it only on the first render:

const  = useState(() => calculateInitialValue());

Handling side effects with useEffect

useEffect is React’s API for running side effects in function components. A “side effect” is anything that touches the outside world: data fetching, logging, direct DOM changes, subscriptions, timers, browser APIs, etc.

Conceptually, useEffect replaces a combination of componentDidMount, componentDidUpdate and componentWillUnmount from class components. Instead of splitting one effect across three lifecycle methods, you declare it once and let React handle when it runs and when it cleans up.

The basic signature is useEffect(setup, dependencies?). The setup function is your effect body; it can optionally return a cleanup function. The dependencies array tells React when the effect needs to re‑run.

useEffect(() => {
  // side effect logic here

  return () => {
    // optional cleanup logic here
  };
}, );

By default, without the second argument, the effect will run after every render (first mount and every subsequent update). That’s often too much for network requests or expensive logic.

A very common pattern is to update something external whenever a piece of state changes. For example, updating the page title depending on the click count:

import React, { useState, useEffect } from 'react';

function Counter() {
  const  = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, ); // effect re-runs only when `count` changes

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

The dependency array is crucial for performance and correctness. It controls when React should re‑run the effect: if any dependency has changed according to Object.is comparison, the effect cleans up and runs again; if none changed, it’s skipped.

Understanding the dependency array like a pro

The dependency array is where most subtle useEffect bugs come from. React compares each element of the array with its previous value using Object.is. If all values are equal, the effect is skipped; if at least one is different, the effect is re‑executed.

There are three main dependency configurations you’ll use all the time:

  • No second argument: the effect runs after every render.
  • Empty array []: the effect runs only once on mount and cleans up on unmount.
  • Array with values : the effect runs after mount and whenever any dependency changes.

When dependencies are primitive values (numbers, strings, booleans), this is straightforward. Problems start when you put objects, arrays or functions inside the dependencies, because equality is reference‑based. Two identical objects with different references are considered “different”, which causes re‑runs on every render.

Consider an effect that depends on a team object from props:

function Team({ team }) {
  useEffect(() => {
    console.log(team.id, team.active);
  }, ); // ⚠️ might re-run every render if `team` reference changes
}

Even if the actual team content doesn’t change, a new object reference on each render will force the effect to run again. To avoid this, either depend on the primitive fields you actually use, or reconstruct the object inside the effect itself.

A safer version only tracks what the effect really needs:

function Team({ team }) {
  const { id, active } = team;

  useEffect(() => {
    console.log(id, active);
  }, );
}

If you truly need the whole object inside the effect, you can recreate it there instead of using it as a dependency. That way the dependency list can still be based on primitives:

function Team({ team }) {
  const { id, active, name } = team;

  useEffect(() => {
    const localTeam = { id, active, name };
    // use `localTeam` here
  }, );
}

As a last resort you can use memoization with useMemo or useCallback for expensive objects or functions, but remember that memoization itself has a cost. Don’t sprinkle it everywhere “just in case”; add it when a specific dependency is really causing performance issues.

Cleaning up effects correctly

Some side effects allocate resources that must be released: subscriptions, sockets, intervals, timeouts, event listeners, etc. Forgetting to clean them up can easily lead to memory leaks or duplicated work.

In useEffect, cleanup is handled by returning a function from the effect. React will call this function before running the effect again with new dependencies, and also one last time when the component unmounts.

import { useEffect } from 'react';

function LogMessage({ message }) {
  useEffect(() => {
    const log = setInterval(() => {
      console.log(message);
    }, 1000);

    return () => {
      clearInterval(log);
    };
  }, );

  return <div>logging to console "{message}"</div>;
}

In this example, every time message changes, React first clears the old interval, then sets up a new one with the updated message. When the component disappears from the UI, the last cleanup clears the interval for good.

This “setup + cleanup” pairing is central to the mental model of useEffect. Try to think of each effect as a self‑contained process that starts in the setup function and completely stops in the cleanup function. React may run multiple setup/cleanup cycles in development (especially under Strict Mode) to stress‑test that your cleanup really undoes everything.

A classic example is subscribing to an external source, like a chat API or browser event (see handling onKeyDown in React):

useEffect(() => {
  function handleClick(event) {
    console.log('Clicked', event.clientX, event.clientY);
  }

  document.addEventListener('click', handleClick);

  return () => {
    document.removeEventListener('click', handleClick);
  };
}, []); // runs once on mount, cleans up on unmount

Using useState and useEffect together for data fetching

One of the most common real‑world combos is using useState and useEffect to fetch data from an API. You keep the data (and maybe loading/error flags) in state, and perform the request in an effect that runs when the component mounts or when some parameter changes.

A basic pattern for fetching data once on mount looks like this:

import { useEffect, useState } from 'react';

function FetchItems() {
  const  = useState([]);

  useEffect(() => {
    let ignore = false;

    async function fetchItems() {
      try {
        const response = await fetch('/items');
        const fetchedItems = await response.json();
        if (!ignore) {
          setItems(fetchedItems);
        }
      } catch (error) {
        console.error('Error fetching items:', error);
      }
    }

    fetchItems();

    return () => {
      // avoid updating state if the component unmounted
      ignore = true;
    };
  }, []);

  return (
    <div>
      {items.map(item => (
        <div key={item.id ?? item}>{item.name ?? item}</div>
      ))}
    </div>
  );
}

Here, the empty dependency array ensures that the request runs exactly once. The internal ignore flag is a simple way to avoid setting state on an unmounted component in case the request resolves late.

It’s also very common to add a loading flag and show a spinner or placeholder while data is on the way:

const Statistics = () => {
  const  = useState([]);
  const  = useState(true);

  useEffect(() => {
    const getStats = async () => {
      try {
        const statsData = await getData();
        setStats(statsData);
      } finally {
        setLoading(false);
      }
    };

    getStats();
  }, []);

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

  return (
    <ul>
      {stats.map(stat => (
        <li key={stat.id}>{stat.label}: {stat.value}</li>
      ))}
    </ul>
  );
};

If your query depends on a parameter (like a category, filter, or route param), add that parameter to the dependency array so the effect re‑runs when it changes:

useEffect(() => {
  async function fetchItems() {
    const response = await fetch(`/items?category=${category}`);
    const data = await response.json();
    setItems(data);
  }

  fetchItems();
}, );

Thinking in “effects on every render” vs “lifecycles”

If you’re used to class components, it can be tempting to mentally map useEffect to mount/update/unmount methods, but that usually leads to more confusion. A simpler mental model is: “effects run after renders, and may clean up before the next run”.

In classes, you often had to duplicate logic between componentDidMount and componentDidUpdate because you wanted the same effect to run both on mount and on updates. With hooks, that duplication disappears: a single effect covers both cases, and React takes care of cleaning up between runs.

This design also eliminates an entire class of bugs around not handling updates correctly. For example, in a class component that subscribes to a friend’s online status, it’s easy to forget to resubscribe when props.friend changes, causing stale subscriptions or crashes on unmount. With useEffect that lists friend.id as a dependency, React will automatically run the cleanup for the old friend and the setup for the new one.

Do keep in mind that in development Strict Mode, React deliberately runs your setup + cleanup cycle twice on mount. This doesn’t happen in production, but it’s a useful stress test to confirm that your cleanup really undoes everything and that your effect can safely run multiple times.

Optimizing and troubleshooting useEffect behaviour

When an effect runs more often than you expect, the first thing to check is the dependency array. Either a dependency changes on every render (common with inline objects/functions) or you forgot to specify the array at all.

Logging the dependency values is a quick way to debug:

useEffect(() => {
  console.log('Effect deps:', dep1, dep2);
}, );

If you see different logs every time, inspect which dependency is actually changing. Often you’ll find an inline object or arrow function being recreated each render. Moving object creation inside the effect, or lifting functions outside the component, or memoizing them with useCallback can stabilize dependencies when needed.

Infinite loops happen when an effect both depends on a value and unconditionally updates that same value. For example:

useEffect(() => {
  setCount(count + 1); // ⚠️ will cause a loop if `count` is a dependency
}, );

Each time count changes, the effect runs, updates count again, triggers another render, and so on. To break that pattern, consider whether the state update truly belongs in an effect, whether it should be triggered by a user interaction instead, or whether you can depend on a different value.

Sometimes you want to read the latest value of some state or props inside an effect without having that value trigger a re‑run. In those advanced scenarios, newer APIs like “effect events” (via useEffectEvent in the React docs) or refs can help, but for most practical cases, staying faithful to dependencies is safer and simpler.

Putting everything together, using useState and useEffect correctly boils down to a few core habits: keep state small and focused, prefer functional updates when deriving new state from old, structure effects around setup/cleanup pairs, be honest and explicit with dependency arrays, and always respect the rules of hooks so React can reliably track what belongs where. When you follow those principles, your components stay predictable, your side effects behave, and your React codebase becomes far easier to evolve as your app grows.

Artículo relacionado:
Solved: How to install react native hooks with
Related posts: