React September 15, 2026 Aditya Rawas 4 min read

React Hooks Explained: useState and useEffect in Depth

React Hooks, introduced in React 16.8, changed how we write components. Before hooks, state and lifecycle logic required class components. Now, useState and useEffect let you add the same capabilities to simple functions.

This guide covers both hooks deeply — how they work under the hood, when to use them, and the subtle bugs that trip up even experienced React developers.


What Are Hooks?

Hooks are functions that let you “hook into” React’s internal state and lifecycle systems from function components. They always start with use.

The rules are strict:

  1. Only call hooks at the top level — never inside loops, conditions, or nested functions
  2. Only call hooks from React function components (or custom hooks)

These rules exist because React tracks hooks by their call order. If a hook runs conditionally, the order changes between renders and React’s internal state tracking breaks.


useState: Managing Component State

The Basics

import { useState } from 'react';

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

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

useState(initialValue) returns an array with two elements:

  • The current state value (count)
  • A setter function (setCount) that triggers a re-render when called

State Updates Are Asynchronous

React batches state updates and re-renders once after event handlers complete. This means reading state immediately after setting it gives the old value:

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

  function handleClick() {
    setCount(count + 1);
    console.log(count); // Still 0 — state hasn't updated yet
  }
}

The Functional Update Form

When the next state depends on the previous state, use the functional update form — pass a function to the setter instead of a value:

// Unsafe — can use a stale value of count
setCount(count + 1);

// Safe — always uses the latest state
setCount((prevCount) => prevCount + 1);

This matters when multiple updates happen in the same render cycle:

function handleTripleIncrement() {
  // Bad — all three read the same stale `count` value, result: count + 1
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);

  // Good — each sees the result of the previous, result: count + 3
  setCount((c) => c + 1);
  setCount((c) => c + 1);
  setCount((c) => c + 1);
}

State with Objects

When state is an object, you must spread the previous state when updating — useState does not merge objects like setState in class components:

const [user, setUser] = useState({ name: "Aditya", age: 28, city: "Pune" });

// Wrong — replaces the entire state object, losing `name` and `city`
setUser({ age: 29 });

// Correct — spread previous state, then override the field you want
setUser((prev) => ({ ...prev, age: 29 }));

Lazy Initial State

If computing the initial state is expensive, pass a function — it only runs once on mount:

// Bad — parses JSON on every render (even though only the first matters)
const [data, setData] = useState(JSON.parse(localStorage.getItem('data')));

// Good — function only runs once
const [data, setData] = useState(() => JSON.parse(localStorage.getItem('data')));

useEffect: Handling Side Effects

What is a Side Effect?

A side effect is anything that reaches outside the component’s render — fetching data, subscriptions, timers, manually updating the DOM, logging.

React’s render must be pure — the same props/state always produces the same JSX. Side effects live in useEffect.

Basic Syntax

useEffect(() => {
  // Side effect code runs after render
  document.title = `Count: ${count}`;
});

Without a second argument, this effect runs after every render. That’s rarely what you want.

The Dependency Array

The second argument to useEffect controls when the effect re-runs:

// Runs once on mount only (empty array = no dependencies)
useEffect(() => {
  fetchData();
}, []);

// Runs on mount AND whenever `userId` changes
useEffect(() => {
  fetchUser(userId);
}, [userId]);

// Runs after every render (omit the array entirely)
useEffect(() => {
  logRender();
});

The rule: every reactive value used inside the effect (props, state, variables derived from them) must be in the dependency array. The react-hooks/exhaustive-deps ESLint rule enforces this automatically.

Fetching Data with useEffect

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

  useEffect(() => {
    let cancelled = false; // prevents stale state updates

    async function fetchUser() {
      try {
        setLoading(true);
        const res = await fetch(`/api/users/${userId}`);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        if (!cancelled) setUser(data);
      } catch (err) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    fetchUser();

    return () => {
      cancelled = true; // cleanup — ignore result if component unmounts
    };
  }, [userId]); // re-fetch whenever userId changes

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <p>{user?.name}</p>;
}

Cleanup Functions

The function returned from useEffect is the cleanup function — React calls it before the effect runs again (due to a dependency change) and when the component unmounts.

useEffect(() => {
  const interval = setInterval(() => {
    setCount((c) => c + 1);
  }, 1000);

  return () => clearInterval(interval); // cleanup — prevents memory leaks
}, []);
useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then((res) => res.json())
    .then(setData);

  return () => controller.abort(); // cancel the in-flight request on cleanup
}, []);

Cleanup is essential for:

  • Cancelling fetch requests
  • Clearing intervals and timeouts
  • Unsubscribing from WebSocket connections or event listeners
  • Cancelling animations

Common useEffect Mistakes

Mistake 1: Missing Dependencies

// Wrong — `userId` is used inside but missing from the array
// The effect runs once on mount with the initial userId, never again
useEffect(() => {
  fetchUser(userId);
}, []); // eslint will warn about this

// Correct
useEffect(() => {
  fetchUser(userId);
}, [userId]);

Mistake 2: Object/Array Dependencies Causing Infinite Loops

Objects and arrays are compared by reference in JavaScript. A new object is created on every render even if its contents are the same — so putting it in the dependency array triggers the effect on every render.

function MyComponent({ config }) {
  // Bug — if config is created inline: <MyComponent config={{ debug: true }} />
  // A new object reference on every parent render → infinite loop
  useEffect(() => {
    setupLibrary(config);
  }, [config]);
}

Solutions:

  • Memoize the object with useMemo
  • Destructure primitive values from the object and use those as dependencies
  • Move the object outside the component if it never changes

Mistake 3: Fetching Without Cleanup

// Bug — if userId changes quickly (e.g. user types in a search box),
// multiple requests are in-flight. The last response might not be
// from the last request — race condition!
useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then((r) => r.json())
    .then(setUser);
}, [userId]);

Always use the cancellation pattern (AbortController or a cancelled flag) shown in the fetch example above.

Mistake 4: Setting State Unconditionally on Unmounted Component

useEffect(() => {
  fetchData().then((data) => {
    setData(data); // might run after component unmounts — React warning
  });
}, []);

Fix with the cancelled flag or AbortController shown earlier.


The Mental Model: Synchronization, Not Lifecycle

The key to using useEffect correctly is to stop thinking about lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount) and instead think about synchronization:

“This effect keeps [external system] synchronized with [these values].”

// Don't think: "run this when userId changes"
// Think: "keep the user profile in sync with userId"
useEffect(() => {
  fetchUser(userId);
  return () => cancelFetch();
}, [userId]);

This framing makes the dependency array obvious — whatever the effect needs to stay synchronized with goes in the array.


useState vs useReducer

As state logic grows more complex, useReducer is often cleaner than multiple useState calls:

// Multiple useState — works but gets unwieldy
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

// useReducer — groups related state together
const [state, dispatch] = useReducer(reducer, {
  name: '',
  loading: false,
  error: null,
});

Prefer useReducer when:

  • Multiple state values that change together
  • Next state depends on complex logic involving previous state
  • You find yourself passing many state setters down to child components

Practical Patterns

function SearchBar({ onSearch }) {
  const [query, setQuery] = useState('');

  useEffect(() => {
    const timer = setTimeout(() => {
      onSearch(query);
    }, 400);

    return () => clearTimeout(timer); // cancel on every keystroke
  }, [query, onSearch]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Window Resize Listener

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

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []); // no deps — addEventListener is stable

  return width;
}

LocalStorage Sync

function usePersistentState(key, defaultValue) {
  const [value, setValue] = useState(
    () => JSON.parse(localStorage.getItem(key) ?? 'null') ?? defaultValue
  );

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

These patterns all follow the same structure: setup in the effect body, cleanup in the return function, dependencies declared honestly.


Key Takeaways

  • useState(initial) returns [currentValue, setter] — calling the setter triggers a re-render with the new state.
  • Use the functional update form setCount(c => c + 1) when the new state depends on the old state.
  • When state is an object, always spread previous state to avoid overwriting unrelated fields.
  • useEffect runs after render. The dependency array controls when it re-runs — an empty array means mount-only.
  • Every reactive value used inside an effect must be in the dependency array — use the ESLint rule exhaustive-deps to enforce this.
  • Always return a cleanup function for subscriptions, timers, and fetch requests to prevent memory leaks and race conditions.
  • Think of useEffect as synchronization, not as lifecycle hooks — this mental model makes dependencies obvious.
  • When state logic gets complex, migrate from multiple useState calls to useReducer.

Never Miss an Article

Stay Updated

Get new deep-dives on JavaScript, TypeScript, Go, and cloud-native engineering delivered to your reader.

Aditya Rawas

Written by

Aditya Rawas

Full-stack engineer writing deep-dives on JavaScript, TypeScript, React, AWS, Docker, and Kubernetes. Passionate about making complex engineering concepts accessible to developers at every level.