Frontend Development

React Hooks Explained: useState and useEffect

Mayur Dabhi
Mayur Dabhi
February 17, 2026
15 min read

You add a useEffect to fetch some data, open the Network tab, and see the same request firing over and over. Or a counter inside a setInterval stays stuck at 1 no matter how long you wait. Almost every React developer hits one of these early on, and both come from the same place: not quite knowing when useState and useEffect actually run.

This post covers those two hooks: how state updates work, what the dependency array really controls, when cleanup runs, and the mistakes that cause infinite loops and stale values. I'll also explain why they behave the way they do, because that's what makes the bugs easier to spot.

Why Hooks Exist

Before Hooks (added in React 16.8), sharing stateful logic between components meant render props or higher-order components, which tend to pile up as wrapper layers. Hooks let you pull that logic into a plain function and reuse it without changing your component tree.

The Evolution: From Classes to Hooks

Class components worked, but they had a few recurring pain points:

Hooks give function components state and lifecycle behavior, which removes most of that friction. Here's the same counter written both ways:

Counter.jsx (Class)
import React, { Component } from 'react';

class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.increment = this.increment.bind(this);
  }

  componentDidMount() {
    document.title = `Count: ${this.state.count}`;
  }

  componentDidUpdate() {
    document.title = `Count: ${this.state.count}`;
  }

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.increment}>Increment</button>
      </div>
    );
  }
}
Counter.jsx (Hooks)
import React, { useState, useEffect } from 'react';

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

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

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

The hooks version is shorter, there's no this to bind, and the title logic lives in one place instead of being copied into two lifecycle methods.

Understanding useState

Call useState with an initial value and you get back a pair: the current value and a function to change it. Calling that function schedules a re-render with the new value.

useState(0) Initial Value returns [count, setCount] count Current State setCount Updater Function

useState returns an array with the current state and an updater function

Basic Syntax

useState Syntax
const [state, setState] = useState(initialValue);

// Examples with different types
const [count, setCount] = useState(0);           // Number
const [name, setName] = useState('');            // String
const [isOpen, setIsOpen] = useState(false);     // Boolean
const [items, setItems] = useState([]);          // Array
const [user, setUser] = useState(null);          // Object or null
const [form, setForm] = useState({               // Object
  username: '',
  email: '',
  password: ''
});
Important: Array Destructuring

The [state, setState] syntax is array destructuring. You can name these variables anything you want, but the convention is value and setValue. The order matters: state first, setter second.

Updating State

There are two ways to update state with the setter function:

State Updates
// 1. Direct value update
setCount(5);
setName('John');
setIsOpen(true);

// 2. Functional update (recommended when new state depends on previous)
setCount(prevCount => prevCount + 1);
setItems(prevItems => [...prevItems, newItem]);
setUser(prevUser => ({ ...prevUser, name: 'Jane' }));
Pro Tip: Use Functional Updates

When the new state depends on the old one, use the functional form: setState(prev => newValue). The count variable is a snapshot from the current render, so calling setCount(count + 1) three times in one handler only adds 1. With setCount(c => c + 1) three times, you get 3.

Working with Objects and Arrays

State updates in React must be immutable. React compares the old and new value by reference, so if you mutate the object and pass the same reference back, it may skip the re-render entirely. Create a new object or array instead:

Immutable Updates
// ❌ WRONG - Mutating state directly
user.name = 'Jane';
setUser(user);

// ✅ CORRECT - Creating new object
setUser({ ...user, name: 'Jane' });
// Or with functional update:
setUser(prev => ({ ...prev, name: 'Jane' }));

// ❌ WRONG - Mutating array
items.push(newItem);
setItems(items);

// ✅ CORRECT - Creating new array
setItems([...items, newItem]);
// Or with functional update:
setItems(prev => [...prev, newItem]);

// Removing from array
setItems(prev => prev.filter(item => item.id !== idToRemove));

// Updating item in array
setItems(prev => prev.map(item => 
  item.id === idToUpdate ? { ...item, completed: true } : item
));

Lazy Initialization

If your initial state requires expensive computation, pass a function to useState instead of a value:

Lazy Initialization
// ❌ This runs on every render
const [data, setData] = useState(expensiveComputation());

// ✅ This only runs once on initial render
const [data, setData] = useState(() => expensiveComputation());

// Practical example: reading from localStorage
const [theme, setTheme] = useState(() => {
  const saved = localStorage.getItem('theme');
  return saved ? JSON.parse(saved) : 'dark';
});

Understanding useEffect

Rendering should only calculate what goes on screen. Anything that reaches outside the component (fetching data, subscribing to events, setting timers, touching the DOM directly) goes in useEffect, which runs after React has committed the render.

Component Renders JSX → DOM Effect Runs After paint Dependency Array No array Runs every render [] Runs once on mount [dep1, dep2] Runs when deps change Cleanup Function Runs before next effect

useEffect lifecycle: render → effect → cleanup (if returned)

Basic Syntax

useEffect Syntax
useEffect(() => {
  // Effect logic here (runs after render)
  
  return () => {
    // Cleanup logic here (optional)
    // Runs before component unmounts
    // Also runs before effect re-runs
  };
}, [dependencies]); // Dependency array (optional)

The Three Dependency Array Patterns

The dependency array decides when your effect runs. Most useEffect bugs I see come down to getting this part wrong:

Pattern Syntax When It Runs Use Case
No Array useEffect(() => {}) Every render Rarely needed; usually a mistake
Empty Array useEffect(() => {}, []) Only on mount Initial data fetch, setup subscriptions
With Deps useEffect(() => {}, [a, b]) When deps change React to state/prop changes

Practical Examples

Example 1: Data Fetching

DataFetching.jsx
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Reset state when userId changes
    setLoading(true);
    setError(null);

    // Create abort controller for cleanup
    const controller = new AbortController();

    async function fetchUser() {
      try {
        const response = await fetch(
          `https://api.example.com/users/${userId}`,
          { signal: controller.signal }
        );
        
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        
        const data = await response.json();
        setUser(data);
      } catch (err) {
        // Ignore abort errors
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchUser();

    // Cleanup: cancel pending request
    return () => controller.abort();
  }, [userId]); // Re-fetch when userId changes

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return null;

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

Example 2: Setting Up Intervals

Timer.jsx
import { useState, useEffect } from 'react';

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    // Only set up interval if timer is running
    if (!isRunning) return;

    const intervalId = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);

    // Cleanup: clear interval when component unmounts
    // or when isRunning changes to false
    return () => clearInterval(intervalId);
  }, [isRunning]); // Re-run when isRunning changes

  return (
    <div>
      <h2>{seconds} seconds</h2>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? 'Pause' : 'Start'}
      </button>
      <button onClick={() => setSeconds(0)}>Reset</button>
    </div>
  );
}

Example 3: Event Listeners

WindowSize.jsx
import { useState, useEffect } from 'react';

function WindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    // Add event listener
    window.addEventListener('resize', handleResize);

    // Cleanup: remove event listener
    return () => window.removeEventListener('resize', handleResize);
  }, []); // Empty array = only on mount/unmount

  return (
    <div>
      <p>Width: {windowSize.width}px</p>
      <p>Height: {windowSize.height}px</p>
    </div>
  );
}

Example 4: Local Storage Sync

useLocalStorage.jsx
import { useState, useEffect } from 'react';

// Custom hook for localStorage sync
function useLocalStorage(key, initialValue) {
  // Initialize state from localStorage or use default
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    return saved ? JSON.parse(saved) : initialValue;
  });

  // Sync to localStorage whenever value changes
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'dark');
  const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);

  return (
    <div>
      <select value={theme} onChange={e => setTheme(e.target.value)}>
        <option value="dark">Dark</option>
        <option value="light">Light</option>
      </select>
      <input
        type="range"
        min="12"
        max="24"
        value={fontSize}
        onChange={e => setFontSize(Number(e.target.value))}
      />
    </div>
  );
}

Common Mistakes and How to Avoid Them

Mistake 1: Missing Dependencies

Missing Dependencies
// ❌ WRONG - count is used but not in dependencies
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // Always logs initial value (0)!
    }, 1000);
    return () => clearInterval(id);
  }, []); // Missing 'count' dependency
}

// ✅ CORRECT - Use functional update to avoid dependency
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1); // Uses latest count via callback
    }, 1000);
    return () => clearInterval(id);
  }, []); // No dependency needed!
}

Mistake 2: Object/Array Dependencies

Object Dependencies
// ❌ WRONG - Creates new object every render, infinite loop!
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  const options = { query, limit: 10 }; // New object each render!

  useEffect(() => {
    fetchResults(options).then(setResults);
  }, [options]); // Always triggers because options !== options
}

// ✅ CORRECT - Stable dependencies
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const limit = 10;

  useEffect(() => {
    fetchResults({ query, limit }).then(setResults);
  }, [query, limit]); // Primitive values are stable
}

// ✅ ALSO CORRECT - useMemo for complex objects
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  const options = useMemo(() => ({
    query,
    limit: 10
  }), [query]);

  useEffect(() => {
    fetchResults(options).then(setResults);
  }, [options]); // Now stable between renders
}

Mistake 3: Forgetting Cleanup

Memory Leaks
// ❌ WRONG - Memory leak! Event listener never removed
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
}, []);

// ✅ CORRECT - Always clean up subscriptions/listeners
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

// ❌ WRONG - State update on unmounted component
useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => setData(data)); // May error if unmounted
}, []);

// ✅ CORRECT - Cancel or ignore on unmount
useEffect(() => {
  let isMounted = true;
  
  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      if (isMounted) setData(data);
    });

  return () => { isMounted = false; };
}, []);
The ESLint Plugin

Install eslint-plugin-react-hooks and turn on the exhaustive-deps rule. It flags missing dependencies before they turn into stale-value bugs. When it complains, the fix is usually to restructure the effect, not to add an eslint-disable comment.

"Why does my effect run twice?"

In development with <React.StrictMode>, React 18 and later mount every component, unmount it, and mount it again. Your effect runs, its cleanup runs, then the effect runs again, so you'll see two fetches in the Network tab. This doesn't happen in a production build. It's there to expose effects without proper cleanup, so treat it as a test rather than something to work around. Also, the old "Can't perform a React state update on an unmounted component" warning was removed in React 18, so the isMounted pattern above is mainly about avoiding stale or out-of-order data, not silencing that warning.

Multiple useState and useEffect

A component can call as many hooks as it needs. I keep values that always change together in one state variable and split unrelated ones apart, with one effect per concern:

Multiple Hooks
function Dashboard() {
  // Separate concerns into different state variables
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [notifications, setNotifications] = useState([]);
  const [isLoading, setIsLoading] = useState(true);

  // Effect for user data
  useEffect(() => {
    fetchUser().then(setUser);
  }, []);

  // Effect for posts (depends on user)
  useEffect(() => {
    if (!user) return;
    fetchPosts(user.id).then(setPosts);
  }, [user]);

  // Effect for notifications (polling)
  useEffect(() => {
    if (!user) return;
    
    const fetchNotifs = () => {
      fetchNotifications(user.id).then(setNotifications);
    };
    
    fetchNotifs(); // Initial fetch
    const id = setInterval(fetchNotifs, 30000); // Poll every 30s
    
    return () => clearInterval(id);
  }, [user]);

  // Effect to update loading state
  useEffect(() => {
    if (user && posts.length > 0) {
      setIsLoading(false);
    }
  }, [user, posts]);

  // ... render logic
}

Rules of Hooks

There are two rules. Break them and React mixes up which state belongs to which hook:

Rule 1: Top Level Only

Don't call hooks inside loops, conditions, or nested functions

Rule 2: React Functions Only

Only call hooks from React function components or custom hooks

Why It Matters

React relies on call order to track hooks between renders

Rules of Hooks
// ❌ WRONG - Hook inside condition
function Form({ isEditing }) {
  if (isEditing) {
    const [value, setValue] = useState(''); // Never do this!
  }
}

// ❌ WRONG - Hook inside loop
function List({ items }) {
  items.forEach(item => {
    useEffect(() => { /* ... */ }); // Never do this!
  });
}

// ✅ CORRECT - Hooks at top level, conditions inside
function Form({ isEditing }) {
  const [value, setValue] = useState('');
  
  useEffect(() => {
    if (isEditing) { // Condition inside hook is fine
      // ... effect logic
    }
  }, [isEditing]);
}

// ✅ CORRECT - For lists, put hook in child component
function List({ items }) {
  return items.map(item => (
    <ListItem key={item.id} item={item} />
  ));
}

function ListItem({ item }) {
  useEffect(() => { /* ... */ }, [item]); // Each instance has its own hook
  return <div>{item.name}</div>;
}

When to Create Custom Hooks

A custom hook is just a function whose name starts with use and that calls other hooks. I reach for one when:

Custom Hook Example
// Custom hook for API calls
function useApi(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    
    setLoading(true);
    setError(null);
    
    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error('Request failed');
        return res.json();
      })
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// Usage - so clean!
function UserProfile({ userId }) {
  const { data: user, loading, error } = useApi(`/api/users/${userId}`);

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <Profile user={user} />;
}

function PostList() {
  const { data: posts, loading } = useApi('/api/posts');
  // ...
}

When I wouldn't reach for useEffect

Cases where an effect is the wrong tool

  • Derived values: If you can calculate something from props or state during render (a filtered list, a full name), just calculate it. Storing it in state and syncing it with an effect adds an extra render and a chance for the two to drift apart. The isLoading effect in the Dashboard example above is a candidate for this: it could be computed directly.
  • Responding to a click: If something happens because the user did something, put it in the event handler. An effect that watches a "submitted" flag to send a request is harder to follow and easier to fire twice.
  • Serious data fetching: The fetch-in-effect pattern is fine for small apps, but it doesn't give you caching, deduplication or retries. Once several components load the same data, I'd use TanStack Query, SWR, or your framework's data loading instead.
  • Values that don't need a re-render: A timer ID or the previous value of a prop belongs in useRef, not useState.
Other hooks worth learning

A few problems in this post have better answers elsewhere:

  • useReducer: when several state values change together and useState calls start to sprawl
  • useRef: mutable values and DOM nodes that shouldn't trigger renders
  • useMemo and useCallback: keeping object and function dependencies stable, as in Mistake 2
  • useContext: reading shared values without passing props through every level

If you only remember one thing, make it this: an effect is for syncing with something outside React. If there's no outside system involved, there's probably a simpler way to write it.

React Hooks useState useEffect JavaScript Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications. Passionate about React, Node.js, and modern web technologies.