React Hooks Explained — useState and useEffect

Photo: Meet Patel / Pexels
React Hooks are functions that let a plain function component remember values between renders and run code in response to what it displays. They became stable in React 16.8 on February 6, 2019, and they are now the default way to write React. Before hooks, state and side effects required class components with lifecycle methods. This guide explains useState and useEffect in depth, including the mental model behind them, the bugs they cause, and the cases where React's own documentation says you should not reach for an effect at all.
Why hooks replaced class components
Class components worked, but they had three recurring problems that the React team set out to fix.
- Logic reuse was awkward. Sharing stateful behavior between components meant higher-order components or render props, which wrapped components in layers of nesting.
- Lifecycle methods split related code apart. Setting up a subscription in
componentDidMountand tearing it down incomponentWillUnmountput two halves of one idea in different places, while unrelated logic got stuffed into the same method. thiswas a trap. Forgetting to bind an event handler in the constructor was a classic source of subtle bugs.
Hooks address each one. Behavior can be extracted into custom hooks, related setup and teardown sit together in one useEffect, and function components have no this. The React team's release post for 16.8 also made a promise that is easy to miss: "we have no plans to remove classes from React," and hooks work alongside class components, so nobody had to rewrite working code.
What a hook is
A hook is a JavaScript function whose name starts with use and that is allowed to call React's built-in features from inside a component. The restriction in that sentence is the whole story, and we return to it in the rules section. For the broader context around the language and tooling React sits in, our web development learning path shows where it fits.
useState: giving a component memory
An ordinary variable inside a function component disappears every time the function runs again. useState adds a value that React keeps for you across renders.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
useState(0) returns exactly two values: the current state and a setter function. The convention is to name them [something, setSomething] with array destructuring. The initial value is used only on the first render and ignored afterward. Calling setCount asks React to re-render the component with the new value.
State is a snapshot, not a live variable
The most important idea in React's documentation about state is that each render sees a fixed snapshot. Think of a photograph: a picture of a clock does not tick, even if the clock on the wall keeps moving. When you call a setter, the variable in your current function does not change. React schedules a new render, and that new render gets a new snapshot.
setName('Robin');
console.log(name); // still the old value in this render
React's reference page lists this as the first pitfall of useState: the setter updates state only for the next render, so reading the variable right after calling it gives the old value. It surprises nearly everyone once.
The same idea explains why this handler adds only one:
function handleClick() {
setAge(age + 1); // setAge(42 + 1)
setAge(age + 1); // setAge(42 + 1)
setAge(age + 1); // setAge(42 + 1)
// Result: 43
}
All three calls used the same snapshot value, 42.
Functional updates: asking for "the latest"
When the next state depends on the previous state, pass an updater function:
function handleClick() {
setAge(a => a + 1); // 42 -> 43
setAge(a => a + 1); // 43 -> 44
setAge(a => a + 1); // 44 -> 45
// Result: 45
}
React queues updater functions and runs them in order during the next render, passing each the pending state. React's docs require them to be pure, meaning they compute the next value from their input and do nothing else. Use the functional form in timers, async callbacks, and any code that may fire several times before a render.
Batching: one render for many updates
React waits until all the code in an event handler has run, then applies the queued updates in one render. Before React 18, that held only inside React's own event handlers. According to the React 18 release post (March 29, 2022), updates inside promises, setTimeout, native event handlers, or any other event are now batched too:
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 18 renders once, not twice
}, 1000);
The React docs note that flushSync exists for the rare case where you must force an update immediately, such as to read the DOM right afterward.
Never mutate state
State is read-only. Replace objects and arrays with new copies instead of editing them in place.
const [form, setForm] = useState({ name: '', email: '' });
// Wrong: mutates the existing object, React may ignore the update
form.name = 'Alice';
setForm(form);
// Wrong: drops the email field
setForm({ name: 'Alice' });
// Right: copy, then change one field
setForm(prev => ({ ...prev, name: 'Alice' }));
Why does mutation fail? React compares the new value with the old using Object.is. If you mutate an object and pass the same reference back, the comparison says "no change," and React skips the re-render. For arrays, use non-mutating operations such as spread, map, and filter:
setTodos([...todos, newTodo]);
setTodos(todos.filter(t => t.id !== id));
Nested objects need a copy at every level on the path to the changed field.
Lazy initialization
If computing the initial value is expensive, pass a function, not the result:
// Calls expensiveComputation on every render (the result is ignored after the first)
const [data, setData] = useState(expensiveComputation());
// Calls it only during initialization
const [data, setData] = useState(() => expensiveComputation());
React's docs describe the function form as an initializer function: it should be pure, take no arguments, and return the initial value. Note that you pass the function itself, not a call to it.
Storing a function in state
Because React treats a function passed to useState or a setter as an initializer or updater, storing an actual function in state needs one extra layer: useState(() => someFunction) and setFn(() => otherFunction). This is rare, but it is a documented pitfall.
useEffect: synchronizing with the outside world
useEffect runs code after React has rendered the component and updated the screen. The official reference is blunt about its purpose: effects are for synchronizing a component with an external system, meaning anything React does not control, such as timers, event subscriptions, network connections, third-party widgets, and the browser's DOM APIs.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
The effect function is the "setup." The optional returned function is the "cleanup." The array at the end is the list of dependencies.
A helpful analogy is a subscription to a newsletter. Setup subscribes you, cleanup unsubscribes you, and the dependencies are the address you subscribed with. If your address changes, you unsubscribe the old one before subscribing the new one. You would not leave both active, and React does not either.
The dependency array
React compares each dependency with the previous render's value using Object.is.
| Form | When the effect runs |
|---|---|
useEffect(fn) |
After every commit of the component |
useEffect(fn, []) |
After the first commit only (plus an extra development cycle, covered below) |
useEffect(fn, [a, b]) |
After the first commit, then after any commit where a or b changed |
The key rule from the docs is that you do not choose the dependencies. Every reactive value your effect reads, meaning props, state, and anything declared in the component body, must be listed. The documentation says not to suppress the linter with eslint-disable, because doing so lies to React about what the effect depends on. The array must have a constant length and be written inline.
Cleanup: undo what setup did
Cleanup runs before the effect re-runs with new values, and once more when the component unmounts. It should mirror setup:
useEffect(() => {
const id = setInterval(() => {
setTick(t => t + 1);
}, 1000);
return () => clearInterval(id);
}, []);
React's docs add a useful smell test: cleanup code without matching setup code is usually a sign something is off. Without cleanup, timers, listeners, and connections pile up, which is how memory leaks and duplicate event handling begin.
Why the effect seems to run twice in development
In development, under Strict Mode, React runs one extra setup, cleanup, setup cycle before the first real setup. This is a deliberate stress test. React 18's release notes describe the sequence: mount the component and create effects, simulate unmounting and destroy effects, then simulate mounting again with the previous state and create effects.
The purpose is to prepare for features that unmount and remount parts of the UI while preserving state. It is development only and does not change production behavior. The React docs give the rule of thumb: the user should not be able to tell the difference between setup running once and setup running as setup, cleanup, setup. If you can tell the difference, the cleanup is missing something.
Effects do not run on the server
React's docs state that effects run only on the client. During server rendering they do nothing. That matters for data fetching, as the next section explains, and it is part of why server-rendered pages carry only a loading state when the data fetch lives in an effect. Our explainer on what server-side rendering solves covers the broader trade-offs.
Fetching data in an effect, honestly
Fetching is a legitimate use of useEffect because it synchronizes with the network. The React reference lists its caveats, though:
- Effects do not run on the server, so server-rendered HTML contains only a loading state.
- Fetching inside effects easily creates network waterfalls, where one request waits for another to finish.
- The data usually is not cached or preloaded, so remounting a component fetches again.
- It is verbose, and it is easy to introduce race conditions.
A race condition is the bug where a slow response for an old request arrives after a fast response for a newer one and overwrites it. The ignore flag in the example above prevents it: cleanup sets ignore = true for the old request, so when it finally resolves, its result is discarded.
The docs recommend using your framework's built-in data fetching or a client-side cache library such as TanStack Query, useSWR, or React Router 6.4 and later, rather than hand-rolling effects everywhere. If you do fetch in effects, you can extract the logic into a custom hook so you can swap the strategy later.
Async effects
The effect callback itself cannot be async, because it must return either nothing or a cleanup function, not a promise. Put the async logic in an inner function:
useEffect(() => {
let ignore = false;
async function fetchData() {
const res = await fetch('/api/data');
const json = await res.json();
if (!ignore) setData(json);
}
fetchData();
return () => { ignore = true; };
}, []);
The bugs everyone writes once
Infinite loops
An effect that sets state, with that same state in its dependency array, loops forever: the effect sets state, the render changes the state, the effect runs again.
// Loops forever
const [data, setData] = useState([]);
useEffect(() => {
setData([...data, 'new item']);
}, [data]);
The usual fix is a functional update, which removes the dependency on data honestly rather than by lying to the linter:
useEffect(() => {
setData(prev => [...prev, 'new item']);
}, []);
Before applying any fix, ask whether you need the effect at all. In most cases of this type you do not.
Stale closures
A closure is a function that remembers the variables around it from when it was created. Each render creates new functions with that render's snapshot, so an effect with missing dependencies keeps reading values from an old render.
// count is stale: this always logs the value from the first render
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []); // count is missing
You have three honest fixes: list count as a dependency so the effect re-synchronizes, use an updater such as setCount(c => c + 1) so the code does not read the value at all, or, for code that should read the latest value without causing a re-run, use the Effect Event feature the React docs describe, useEffectEvent, which must be omitted from the dependency array.
const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, shoppingCart.length);
});
useEffect(() => {
onVisit(url);
}, [url]);
Objects and functions as dependencies
Objects and functions created during render are new on every render, so listing them as dependencies makes the effect re-run every time. The docs' fix is to create them inside the effect, where they are no longer reactive values from the component body:
useEffect(() => {
const options = { serverUrl, roomId };
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
You might not need an effect
One of the most useful pages in the React docs is titled "You Might Not Need an Effect." Its core claim is that if no external system is involved, you probably do not need an effect, and removing unnecessary effects makes code easier to follow, faster, and less error-prone. Four patterns account for most of the misuse.
Derived state belongs in the render body
Do not copy values from props or state into new state with an effect. Compute them.
// Redundant state plus an effect causes an extra render pass
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// Just calculate it
const fullName = firstName + ' ' + lastName;
User actions belong in event handlers
Ask why the code runs. If a click caused it, put it in the click handler. Effects are for code that runs because the component was displayed. The docs give the example of a "added to cart" notification placed in an effect: it would reappear on every page load when the product is already in the cart.
Resetting state on a prop change: use a key
If a component should start fresh when an identifier changes, give it a key:
export default function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />;
}
Changing the key makes React unmount and remount the component, discarding its state and its children's state. This replaces a pattern of effects that tried to reset state manually.
Chains of effects and parent notification
Chaining effects that set state to trigger each other causes extra renders and fragile code. Compute what you can during rendering and set all the next state in one event handler. Likewise, do not call a parent's onChange from an effect; update both components' state in the same event, which React batches into one render pass.
The React docs end with a rule of thumb worth memorizing:
- Can it be calculated from props or state? Calculate it during rendering.
- Was it caused by a user action? Put it in an event handler.
- Is it caused by the component appearing, or by synchronizing with an external system? Use an effect.
The rules of hooks, and the reason behind them
React enforces two rules, and the plugin eslint-plugin-react-hooks checks them automatically.
- Call hooks only at the top level of a component or custom hook. Never inside conditions, loops, after an early return, inside event handlers, inside class components, inside functions passed to
useMemo,useReducer, oruseEffect, or insidetry/catch/finallyblocks. - Call hooks only from React function components or custom hooks, not from regular JavaScript functions.
// Illegal: hook inside a condition
if (isLoggedIn) {
const [name, setName] = useState('');
}
// Legal: hook at the top, condition afterward
const [name, setName] = useState('');
if (!isLoggedIn) return null;
React's Rules of Hooks page states the rules but does not itself spell out why, so the explanation here comes from how hooks are implemented. React does not know the names of your state variables. It tracks hooks by the order in which they are called during a render, much like numbered lockers: the first useState call gets locker one, the second gets locker two. If a condition skips one call on a later render, every call after it shifts to the wrong locker, and React hands back the wrong state. Calling hooks unconditionally at the top level guarantees the same order every time.
The React 19 use API is a documented exception: unlike other hooks, use can be called conditionally, such as after an early return. It reads a promise or a context during render.
Beyond useState and useEffect: four hooks worth knowing
useRef: a value that does not trigger renders
useRef returns an object with a single current property that stays the same across renders. Changing it does not trigger a re-render, which makes it the right tool for information that does not affect what is displayed, such as a timer ID, or for holding a DOM node.
useRef |
useState |
|
|---|---|---|
| Persists between re-renders | Yes | Yes |
| Changing it triggers a re-render | No | Yes |
| Right for displayed information | No | Yes |
| Updated by | Setting .current |
The setter function |
React's docs add a firm rule: do not read or write ref.current during rendering, except for initialization. Read and write refs in event handlers and effects.
useReducer: moving update logic out of handlers
useReducer(reducer, initialArg, init?) returns the state and a dispatch function. A reducer is a pure function that takes the current state and an action and returns the next state. By convention an action is an object with a type field.
function reducer(state, action) {
switch (action.type) {
case 'incremented_age':
return { ...state, age: state.age + 1 };
case 'changed_name':
return { ...state, name: action.nextName };
}
throw Error('Unknown action: ' + action.type);
}
dispatch({ type: 'changed_name', nextName: e.target.value });
The docs describe it as very similar to useState, but it lets you consolidate state update logic in one function outside the component. It suits state with several related fields or many kinds of updates. For shared state across many components, see our comparison of Zustand and Redux.
useMemo and useCallback: caching, used sparingly
useMemo caches the result of a calculation between renders. useCallback caches a function definition. React's reference says caching a function helps in two situations: when you pass it to a component wrapped in memo, or when it is a dependency of another hook. Elsewhere there is no benefit, and wrapping everything makes code harder to read.
To decide whether a calculation is worth memoizing, the docs suggest wrapping it in console.time and console.timeEnd: if the logged time is consistently about 1 millisecond or more, memoization may help. Test with CPU throttling and a production build, because development timings, including Strict Mode double rendering, are not representative.
React Compiler 1.0, released October 7, 2025, changes this picture. It is a build-time tool that automatically memoizes components and hooks. The React team says useMemo and useCallback still work as an escape hatch, mainly for keeping a value stable when it is an effect dependency, and recommends relying on the compiler for new code. For existing code, they suggest leaving manual memoization in place or testing carefully before removing it.
React 19 additions
React 19 became stable on December 5, 2024. Its release post describes Actions, which are async functions used in transitions, and several new APIs: useActionState, which returns [state, submitAction, isPending]; useOptimistic, which shows a value immediately while an async request runs; useFormStatus, which reads the status of the parent form; and use, which reads a promise or context during render. Function components can also receive ref as a regular prop, so forwardRef is no longer needed in new code. None of this replaces useState and useEffect, but it moves some of the work that effects used to do, such as tracking pending states for form submissions, into purpose-built APIs.
Building custom hooks
A custom hook is a function whose name starts with use and that calls other hooks. It is how you share stateful logic without adding component nesting.
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return width;
}
function Header() {
const width = useWindowWidth();
return <p>Window is {width}px wide</p>;
}
Two clarifications. First, each component that calls a custom hook gets its own independent state; hooks share logic, not data. Second, window.innerWidth does not exist during server rendering, so in a framework that renders on the server, initialize the state with a safe default and read the real width inside the effect.
Custom hooks pair well with TypeScript, whose types make hook return values self-documenting; see TypeScript for beginners.
Where hooks fit in the wider React ecosystem
Hooks are part of React itself, so they work the same whether you use a bundler like Vite or a framework such as Next.js. The choice between a bare React app and a framework affects where effects run and where data fetching should live. Our comparison of Next.js and React covers that decision, and React versus Vue versus Svelte puts hooks beside the equivalent ideas in other frameworks. If you are weighing whether you need a JavaScript framework at all, see htmx versus React.
Effects can also hurt performance when misused: needless re-renders and repeated fetches show up directly in loading metrics, which our web performance optimization guide explains how to measure. For tooling, the build tools comparison shows how Vite, webpack, and Turbopack compile React code, and CSS-in-JS versus Tailwind covers the styling choices that often go with it.
Frequently asked questions
Q: When should I use useState versus useReducer?
A: Use useState for simple, independent values. Consider useReducer when several fields change together or when many different actions update the same state, because it lets you keep all update logic in one pure function outside the component.
Q: Why does my useEffect run twice in development? A: In Strict Mode, React 18 and later runs an extra setup, cleanup, setup cycle for each effect on mount. It is a development-only stress test to expose missing cleanup. If the double run causes a visible problem, such as a duplicate connection, your cleanup does not fully undo your setup.
Q: Can I call useState inside a loop or condition?
A: No. React tracks hooks by call order, so every render must call the same hooks in the same order. If you need a variable number of values, store an array or object in one useState call, or move the state into a child component that renders once per item.
Q: What is a stale closure in React hooks?
A: It is a function that captured a value from an earlier render and keeps reading it after state has changed. Fix it by listing the value as a dependency, using an updater function such as setCount(c => c + 1), or, for reading the latest value without re-running, using useEffectEvent.
Q: Is useEffect the same as componentDidMount?
A: Not exactly. With an empty dependency array it runs after the first render, like componentDidMount, and its cleanup runs on unmount, like componentWillUnmount. But it is designed around synchronization: it can also re-run when dependencies change, and in development Strict Mode runs an extra setup and cleanup cycle.
Q: Do I need useCallback for every function I pass as a prop?
A: No. React's documentation says caching a function matters mainly when you pass it to a component wrapped in memo or use it as a dependency of another hook. With React Compiler enabled, much of that memoization happens automatically. Measure with the Profiler before adding it.
Q: Why does calling setState not change my variable right away? A: Because state is a snapshot for the current render. The setter schedules a re-render, and the next render receives the new value. If you need the new value immediately, compute it into a local variable first, and pass that variable both to the setter and to whatever else needs it.
The takeaway
Hooks work because React keeps a list of values per component and hands them back in the order you ask for them. useState stores a value and gives each render a fixed snapshot of it. useEffect lets a component stay in sync with something React does not control, and the dependency array is a promise about what that something depends on.
Most hook bugs come from breaking one of three promises: keeping hooks in the same order, treating state as read-only, and telling the truth about dependencies. And the most valuable habit React's own documentation teaches is restraint. Before writing an effect, check whether the value can be computed during rendering or handled in an event. If it can, the effect is not needed.
Sources
- React team, React v16.8: The One With Hooks (February 6, 2019).
- React team, React v18.0 (March 29, 2022).
- React team, React 19 (December 5, 2024).
- React team, React Compiler v1.0 (October 7, 2025).
- React documentation: useState, useEffect, useRef, useReducer, useCallback.
- React documentation: Rules of Hooks and You Might Not Need an Effect.
Related Articles
- Build Tools Comparison 2026 — Vite, webpack, and Turbopack compared
- CSS-in-JS vs Tailwind — Styling strategies for React apps
- Complete Web Dev Learning Path 2026 — Where React fits in the broader stack
Follow new articles
Add SciHub101 to your feed reader to see new science and technology stories.
About the Author
SciHub101 is an independent science and technology publication founded and edited by Buğra Sözeri, a software engineer and app developer based in Türkiye. AI tools assist with drafting and research. Source documentation in the existing archive is uneven, and articles are being reviewed and corrected. The Editorial Team byline identifies editorial responsibility; it does not certify specialist or independent human review. Where we compare products, anything built by our own team is labelled as such — see the editorial guidelines and About page for how corrections and disclosures work.
Related Articles

ORMs 2026 — Prisma vs Drizzle vs Kysely Honest Comparison
Real comparison of TypeScript ORMs in 2026 — Prisma, Drizzle, Kysely, TypeORM — for type safety, performance, edge compatibility, and migration story.

CSS Flexbox and Grid — Complete Layout Guide
Master CSS Flexbox and Grid with practical code examples. Learn when to use each, key properties, and common layout patterns every developer needs.

Next.js vs React 2026 — Which Should You Learn First?
Next.js is a framework built on React, not a rival. What each does, Server Components, App vs Pages Router, and when to pick which, per the official docs.

React vs Vue vs Svelte 2026 — Which Framework to Learn?
Compare React, Vue, Svelte side-by-side. Job market demand, learning curve, salary, real-world usage. Which one to choose.

Zustand vs Redux — React State Management
Zustand vs Redux compared: bundle size, boilerplate, API, when to use each, plus how Jotai, Valtio, MobX, and TanStack Query fit in 2026.

What Is HTMX? Hypermedia for Modern Web
HTMX explained: how this 14KB library lets you build dynamic web apps with HTML attributes instead of a JavaScript framework, plus how it actually works.