useState and useEffect: Using Them Correctly

1. Overview

useState and useEffect are React’s two most fundamental hooks, yet also where the most bugs originate: infinite component renders, state not updating as expected, effects running repeatedly when unnecessary. This article walks through how these two hooks actually work, the correct patterns to use them, and common mistakes you should avoid. After reading, you’ll feel more confident answering the question many React developers still get wrong: “Do I really need useEffect here?”

2. useState Basics

useState returns a pair [value, setter]. Each time you call the setter, React schedules a re-render of the component with the new value.

import { useState } from "react";

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

  return (
    <div>
      <p>Number of customers: {count}</p>
      <button onClick={() => setCount(count + 1)}>Add Customer</button>
    </div>
  );
}
  • useState(0) initializes count = 0 only on the first render.
  • setCount(count + 1) triggers a new render with count incremented by 1.
  • The count captured in the closure of the current render is immutable — it doesn’t change between renders on its own.

Note: Calling setCount doesn’t immediately change the count value within the same render. React batches updates and re-renders afterward.

3. useState: Updating Based on Old State

If you call the setter multiple times in the same event handler based on count (a captured variable), the result will be wrong.

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // Result: count only increases by 1, not 3
}

Use a functional update to always get the latest value:

function handleTripleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  // Result: count correctly increases by 3
}
  • When you pass a function to the setter, React ensures prev is always the latest state value at the time of processing, not the value captured in an old closure.
  • Rule: if the new state depends on the old state, always use a functional update.

4. useState: Lazy Initial State

If the initial value requires expensive computation, don’t call the function directly — pass the function to useState so it runs only once.

function parseInitialOrders(raw) {
  console.log("Parsing order data...");
  return JSON.parse(raw);
}

function OrderList({ rawOrders }) {
  // WRONG: parseInitialOrders runs on every render
  // const [orders, setOrders] = useState(parseInitialOrders(rawOrders));

  // CORRECT: runs only on the first render
  const [orders, setOrders] = useState(() => parseInitialOrders(rawOrders));

  return <p>Number of orders: {orders.length}</p>;
}
  • The wrong way calls parseInitialOrders on every render, even though React only uses the result from the first one.
  • The correct way passes a function reference; React only invokes it on the initial mount.

5. useEffect Basics

useEffect runs a side effect after the component has finished rendering (after the DOM has been updated).

import { useEffect, useState } from "react";

function PageTitleUpdater({ orderCount }) {
  useEffect(() => {
    document.title = `You have ${orderCount} orders`;
  });

  return <p>Current orders: {orderCount}</p>;
}
  • Without a dependency array (the second part), this effect runs after every render — both mount and update.
  • document.title is a typical side effect: it interacts with the world outside React (DOM, APIs, subscriptions, timers), not just computing values for rendering.

6. useEffect: Dependency Array

The dependency array determines when the effect runs. There are 3 cases you need to remember clearly.

6.1. No dependency array

useEffect(() => {
  console.log("Run after EVERY render");
});

6.2. Empty dependency array []

useEffect(() => {
  console.log("Run ONCE after mount");
}, []);

6.3. Dependency array with values

function CustomerProfile({ customerId }) {
  const [customer, setCustomer] = useState(null);

  useEffect(() => {
    fetchCustomer(customerId).then(setCustomer);
  }, [customerId]);

  return <p>{customer?.name ?? "Loading..."}</p>;
}
  • The effect only re-runs when customerId changes between renders (compared using Object.is).
  • If you omit customerId from the array, the effect will use the old customerId value (stale closure) — fetching the wrong customer when the prop changes.

Note: The ESLint plugin eslint-plugin-react-hooks with the exhaustive-deps rule will warn you if you miss a dependency. Don’t disable this rule unless you fully understand the consequences.

7. useEffect: Cleanup Function

When an effect creates a subscription, timer, or event listener, you must return a cleanup function to prevent leaks.

function OrderStatusListener({ orderId }) {
  const [status, setStatus] = useState("pending");

  useEffect(() => {
    const subscription = subscribeToOrderStatus(orderId, setStatus);

    return () => {
      subscription.unsubscribe();
    };
  }, [orderId]);

  return <p>Status: {status}</p>;
}
  • The cleanup function runs before the effect re-runs (when orderId changes) and when the component unmounts.
  • Without cleanup, each time orderId changes creates another new subscription — eventually setStatus gets called from many old subscriptions, causing incorrect updates or memory leaks.
Order of execution when orderId changes from 1 -> 2:
1. cleanup of the effect with orderId = 1
2. new effect runs with orderId = 2

8. Common Mistakes

8.1. Infinite loop due to missing correct dependencies

function BadExample() {
  const [data, setData] = useState({ count: 0 });

  useEffect(() => {
    // new object every render -> effect always sees a "change" if you include data in deps
    setData({ count: data.count + 1 }); // causes re-render -> effect runs again -> infinite
  }, [data]);

  return <p>{data.count}</p>;
}

Fix it with a functional update and separate the stopping condition, or remove useEffect entirely if this logic doesn’t really need a side effect.

8.2. Stale closure in setTimeout/setInterval

function Countdown() {
  const [seconds, setSeconds] = useState(10);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((prev) => prev - 1); // use functional update, NOT seconds directly
    }, 1000);

    return () => clearInterval(id);
  }, []); // empty deps since we don't read seconds directly in the effect

  return <p>{seconds}</p>;
}
  • If you write setSeconds(seconds - 1) instead of using a functional update, the closure will always “remember” seconds = 10 from the first time the effect ran — the countdown gets stuck at the wrong value.

8.3. Using useEffect to calculate derived values

// WRONG: redundant, causes an extra render
function OrderSummaryBad({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price, 0));
  }, [items]);

  return <p>Total: {total}</p>;
}

// CORRECT: calculate directly during render
function OrderSummaryGood({ items }) {
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Total: {total}</p>;
}

The “WRONG” version wastes an extra render cycle: render with the old total, effect runs, setTotal triggers a second render with the correct value. The “CORRECT” version calculates during render, needs no effect, needs no auxiliary state.

9. When NOT to Use useEffect

Note: useEffect is only for synchronizing with systems outside React (DOM, APIs, subscriptions, timers). If you can compute a value directly from current props/state, don’t use an effect.

Three signs you’re using it wrong:

  • You use an effect to compute a derived value from other state (use a regular variable or useMemo).
  • You use an effect to handle user events (handle directly in onClick, onSubmit handlers).
  • You use an effect to reset state when a prop changes — you can usually use the key prop to make React remount the component instead of relying on an effect.
// Instead of using an effect to reset the form when customerId changes:
// useEffect(() => { setForm(initialForm); }, [customerId]);

// Use key to remount the component, React automatically resets all internal state:
<CustomerForm key={customerId} customerId={customerId} />

10. Writing Tests for Hook Behavior

Use Jest with React Testing Library to test actual behavior instead of implementation details.

npm install --save-dev @testing-library/react @testing-library/user-event jest
// CustomerCounter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomerCounter } from "./CustomerCounter";

test("increment count when clicking the Add Customer button", async () => {
  const user = userEvent.setup();
  render(<CustomerCounter />);

  expect(screen.getByText("Number of customers: 0")).toBeInTheDocument();

  await user.click(screen.getByRole("button", { name: "Add Customer" }));

  expect(screen.getByText("Number of customers: 1")).toBeInTheDocument();
});
  • The test calls render, then simulates a real click via userEvent, mimicking user behavior.
  • Assertions are based on displayed content (getByText), not internal state — this is the right way to test React: test behavior, not implementation.
PASS  ./CustomerCounter.test.jsx
  ✓ increment count when clicking the Add Customer button (45 ms)

11. Conclusion

useState looks simple on the surface but is prone to bugs when you forget that the value captured in a closure is “frozen” for each render — use a functional update when new state depends on old state. useEffect is powerful but should only be used to synchronize with systems outside React; if a value can be computed directly during render, don’t wrap it in an effect. Mastering the dependency array and cleanup function will help you avoid most of the common bugs related to these two hooks.

reacthooksjavascriptfrontendtypescript