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)initializescount = 0only on the first render.setCount(count + 1)triggers a new render withcountincremented by 1.- The
countcaptured in the closure of the current render is immutable — it doesn’t change between renders on its own.
Note: Calling
setCountdoesn’t immediately change thecountvalue 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
previs 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
parseInitialOrderson 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.titleis 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
customerIdchanges between renders (compared usingObject.is). - If you omit
customerIdfrom the array, the effect will use the oldcustomerIdvalue (stale closure) — fetching the wrong customer when the prop changes.
Note: The ESLint plugin
eslint-plugin-react-hookswith theexhaustive-depsrule 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
orderIdchanges) and when the component unmounts. - Without cleanup, each time
orderIdchanges creates another new subscription — eventuallysetStatusgets 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 = 10from 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:
useEffectis 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,onSubmithandlers). - You use an effect to reset state when a prop changes — you can usually use the
keyprop 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 viauserEvent, 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.