React Hooks Explained: useState and useEffect for Beginners
A clear, beginner-friendly guide to the two most important React Hooks. Learn what useState and useEffect actually do, when to reach for each, and how they work together to build interactive interfaces in 2026.
Every modern React app you read is built from two ideas: a component returns what should appear on screen, and hooks let that component remember things and react to changes. The two hooks you will reach for first — useState and useEffect — cover an enormous percentage of real production code. Get them right and the rest of React clicks into place quickly.
This guide explains exactly what each hook does, when to use which, and how they work together. If React itself is brand new, start there first; this article assumes you can read a basic component.
Why Hooks Exist
Before 2019, React components that needed state had to be classes with this.state, this.setState, and componentDidMount lifecycle methods. The ergonomics were rough and patterns like "share this stateful logic across components" required gymnastics. Hooks let function components do everything classes could do, with much less ceremony. In 2026 you almost never see a class component in new code.
The rules are simple and the linter (eslint-plugin-react-hooks, included by default in modern setups) enforces them:
- Call hooks only at the top level of a component or a custom hook — never inside loops, conditions, or nested functions.
- Call them in the same order on every render.
Both rules exist because React tracks hooks by call order, not by name.
useState: Memory for a Component
A function component normally forgets everything between renders. useState gives it a value that survives, and a setter that triggers a re-render when called.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}useState(0) returns [currentValue, setterFunction]. You destructure into whatever names make sense. When setCount runs, React schedules a re-render, calls the function again, and count is now the new value.
The state can be any type — number, string, boolean, object, array. For arrays and objects, always create a new reference when updating. React compares by reference, so mutating in place does not trigger a re-render.
// wrong: mutating, React will not see the change
items.push(newItem); setItems(items);
// right: new array reference
setItems([...items, newItem]);When the new state depends on the old state, pass a function to the setter. This avoids stale-closure bugs that come up in event handlers and timers.
setCount(prev => prev + 1);useEffect: Reacting to Renders
Some work has to happen because the UI rendered: fetching data, subscribing to a websocket, setting a document title, registering an interval. That work is called a side effect, and useEffect is where it goes.
import { useEffect, useState } from "react";
function PageTitle({ user }) {
useEffect(() => {
document.title = `Hello, ${user.name}`;
}, [user.name]);
return <h1>Welcome</h1>;
}useEffect takes a function (the effect) and a dependency array. React runs the effect after the render commits, and re-runs it whenever any value in the array changes. Three dependency-array patterns to recognise:
[]— run once after the first render only.[a, b]— run after the first render and any timeaorbchanges.- omitted entirely — run after every render. Almost never what you want.
If your effect sets up something that needs cleaning up (an interval, an event listener, a websocket), return a cleanup function. React calls it before re-running the effect and when the component unmounts.
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(id);
}, []);A Real Example: Fetch on Mount
The classic "load some data when the component appears" pattern combines both hooks. State holds the data, an effect fetches it.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/users/${userId}`, { signal: ctrl.signal })
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(setUser)
.catch(e => e.name !== "AbortError" && setError(e));
return () => ctrl.abort();
}, [userId]);
if (error) return <p>Failed to load.</p>;
if (!user) return <p>Loading…</p>;
return <h1>{user.name}</h1>;
}Notice the AbortController. If userId changes before the previous fetch completes, the old request is cancelled and the cleanup function runs. Without it, an out-of-order response could overwrite a newer one. This pattern is the canonical way to do data fetching with raw useEffect.
In real apps you usually reach for TanStack Query or your framework's built-in data layer instead, because they handle caching, retries, and deduplication for you. But understanding the manual version makes everything else clearer.
Common Mistakes Beginners Make
- Mutating state in place. Always pass a new object or array to the setter.
- Forgetting the dependency array. Without it, an effect runs after every render — usually causing infinite loops.
- Lying to the dependency array. If the effect uses
userId, listuserId. The eslint rule will warn you. - Putting fetches outside
useEffectdirectly in the component body. They run during render, fire on every re-render, and break SSR. - Reaching for
useEffectfor derived data. If you can compute a value from props or state, just compute it — no hook needed.
Quick Reference
const [v, setV] = useState(initial);- Setter triggers a re-render; updates are batched
- For new value based on old:
setV(prev => prev + 1) useEffect(() => { ... }, [deps])— runs after render when deps change- Return a cleanup function to undo subscriptions, intervals, listeners
[]runs once, no array runs every render (rarely correct)- For data fetching in production, prefer TanStack Query, SWR, or your framework's data layer
- Always destructure props that the effect uses, and list them as deps
Rune AI
Key Insights
useStategives a component memory; the setter triggers a re-render.- Always create new references for arrays and objects when updating state.
useEffectruns side effects after render; the dependency array controls when it re-runs.- Return a cleanup function to undo subscriptions, intervals, or in-flight fetches.
- Trust the React Hooks ESLint rule — it catches almost every common bug before runtime.
Frequently Asked Questions
Why does my effect run twice in development?
Can I call hooks conditionally?
Should I use `useState` or `useReducer`?
When should I avoid `useEffect`?
Are class components dead?
Conclusion
useState is memory; useEffect is reaction. Almost every interactive React component you will ever write is some combination of those two patterns. Build a counter, then a fetch-on-mount user profile, then a small TODO list with localStorage persistence — by the third project the hooks rules are second nature.