A real UI needs to remember things between interactions: which tab is open, what's typed in a search box, whether a modal is visible, what's in a shopping cart.
React calls this kind of per-component memory state. State is data that can change over time and, when it does, causes the component to re-render with the new values on screen.
You might wonder: why can't we just use a regular variable for this? Let's look at exactly why that doesn't work, because understanding the problem makes the solution click much faster.
Here's a counter component using a plain variable:
function Counter() {
let count = 0; // regular variable
function handleClick() {
count = count + 1;
console.log('Count is now:', count); // this logs correctly...
}
return (
<div>
<p>Count: {count}</p> {/* ...but this never updates */}
<button onClick={handleClick}>Add one</button>
</div>
);
}
If you run this, you'll see the console logs incrementing correctly⦠so the variable is changing. But the screen never updates. Two things are working against you here:
Counter, it calls the function from scratch and resets count to 0. Any changes you made are gone.To update what's on screen, two things need to happen together: the new value must be stored somewhere React can find it between renders, and React must be told to re-render. useState does both!
useStatehttps://www.youtube.com/watch?v=SpDG283b4bw
useState is a Hook: a special function provided by React that lets you add state to a component. You import it and call it at the top level of your component:
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Add one</button>
</div>
);
}
Now every click updates the screen. Let's break down what's happening.
useStateconst [count, setCount] = useState(0);
useState(0) returns an array of exactly two items. You destructure it into a name pair: the current value and a function to update it. The 0 is the initial value, used only on the first render.
By convention, the pair is named [something, setSomething]. You'll see this pattern everywhere: [isOpen, setIsOpen], [email, setEmail], [items, setItems]. Follow it and it makes your code immediately readable to other React developers.
When you call setCount(count + 1), two things happen:
On the next render, useState(0) is called again, but React ignores the 0 and instead returns the stored value. The component re-runs with the updated count, and the screen shows the new number.
<aside> π‘
Info
Hooks β functions starting with use β can only be called at the top level of your component, never inside conditions, loops, or nested functions. This is because React identifies each useState call by its position in the call order. If that order changes between renders (because a hook is inside an if block that sometimes runs and sometimes doesn't), React loses track of which state belongs to which variable. Your linter (eslint-plugin-react-hooks) will warn you if you break this rule.
</aside>
A component can have as many state variables as it needs, each independent of the others:
function ProductCard({ product }) {
const [quantity, setQuantity] = useState(1);
const [isWishlisted, setIsWishlisted] = useState(false);
const [reviewsVisible, setReviewsVisible] = useState(false);
return (
<div>
<h2>{product.name}</h2>
<button onClick={() => setIsWishlisted(!isWishlisted)}>
{isWishlisted ? 'β₯ Wishlisted' : 'β‘ Add to wishlist'}
</button>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min="1"
/>
<button>Add to cart</button>
<button onClick={() => setReviewsVisible(!reviewsVisible)}>
{reviewsVisible ? 'Hide reviews' : 'Show reviews'}
</button>
{reviewsVisible && <ReviewList productId={product.id} />}
</div>
);
}
Use separate state variables when the values are unrelated and change independently, as they do here. If you find yourself always changing two variables at the same time, that's a signal they might belong together in a single object.
<aside> π‘
Hands On
Build a LightSwitch component with a boolean state variable called isOn. Render a button that says "Turn on" when off and "Turn off" when on, and changes the background colour of a surrounding <div> based on the state. Try using the React DevTools browser extension to inspect your component's state live as you click.
</aside>
Understanding the render cycle helps you avoid confusion later. When you call a setter function:
useState returns the new value instead of the initial one.A critical detail: state doesn't change mid-render. Within a single event handler, the state value you read is fixed β it's a snapshot from when that render started.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
console.log(count); // 0
setCount(count + 1);
console.log(count); // still 0 β count hasn't changed yet
// The new value (1) will be available on the next render
}
return <button onClick={handleClick}>Count: {count}</button>;
}
This surprises many developers the first time. The setter doesn't mutate count in place: it schedules an update. The new value becomes available when the component re-renders.
React also batches state updates from within the same event handler. This means if you call multiple setters in one handler, React groups them into a single re-render rather than re-rendering after each one.
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
function handleReset() {
setFirstName(''); // React doesn't re-render yet
setLastName(''); // React doesn't re-render yet
// React re-renders once here, after the handler finishes
}
return (/* ... */);
}
This is generally what you want: batching makes React faster and avoids partially-updated UIs.
There's one situation where the "snapshot" behaviour bites you: when you need to apply multiple updates to the same state variable in a single handler. Consider this:
// β This only increments by 1, not 3
function handleTripleClick() {
setCount(count + 1); // setCount(0 + 1) β queues 1
setCount(count + 1); // setCount(0 + 1) β queues 1 again
setCount(count + 1); // setCount(0 + 1) β queues 1 again
}
All three calls read the same snapshot value of count (which is 0), so they all queue the same update. The result is 1, not 3.
The fix is to pass a function to the setter instead of a value. That function receives the most recent queued state as its argument:
// β
This correctly increments by 3
function handleTripleClick() {
setCount(n => n + 1); // n is 0 β queues 1
setCount(n => n + 1); // n is 1 β queues 2
setCount(n => n + 1); // n is 2 β queues 3
}
This is called a functional update (sometimes called an updater function). Instead of saying "set count to count + 1", you're saying "set count to whatever it currently is, plus 1." React processes the queue in order, feeding each result as input to the next.
You should use functional updates any time your new state value depends on the previous state value, particularly when the same state might be updated multiple times in quick succession.
<aside> π‘
Info
In practice, you won't need functional updates constantly, because most state updates are straightforward replacements. But they're the correct tool any time your next value derives from the previous one. A common real-world case: toggling. setIsOpen(prev => !prev) is more reliable than setIsOpen(!isOpen) when the toggle might fire multiple times rapidly.
</aside>
<aside> β¨οΈ
Hands On
Build a simple tally counter with "+" and "-" buttons. Then add a "+5" button that calls setCount(n => n + 1) five times in a row. Verify it increments by 5. Then try replacing those with setCount(count + 1) five times and confirm it only increments by 1. This makes the difference concrete.
</aside>
With primitives (numbers, strings, booleans), updating state is straightforward β you just pass the new value:
setCount(count + 1);
setName('Fatima');
setIsOpen(false);
Objects and arrays need more care. React detects state changes by comparing the old and new values. For objects and arrays, it does a reference comparison: it checks whether it's the same object in memory, not whether the contents are the same.
If you mutate an object that's already in state, the reference doesn't change, so React thinks nothing happened and skips the re-render.
The rule from the React docs is worth remembering: treat everything you put into state as read-only. Even if JavaScript allows you to mutate an object, you shouldn't β not when that object lives in state.
// β Mutating state directly β React won't see this change
function handleAgeChange() {
user.age = 30; // same object reference, React doesn't re-render
setUser(user);
}
// β
Creating a new object β React sees a new reference and re-renders
function handleAgeChange() {
setUser({ ...user, age: 30 });
}
The spread operator (...user) copies all the existing properties into a new object, then you override the ones you want to change. This gives React a brand new object reference, which triggers the re-render correctly.
Here's a realistic form example. When a user edits a field, you need to update just that field while keeping the rest of the data intact:
function ProfileForm() {
const [profile, setProfile] = useState({
name: 'Amara',
email: '[email protected]',
city: 'Amsterdam',
});
function handleChange(e) {
setProfile({
...profile, // copy all existing fields
[e.target.name]: e.target.value, // override the one that changed
});
}
return (
<form>
<input
name="name"
value={profile.name}
onChange={handleChange}
placeholder="Name"
/>
<input
name="email"
value={profile.email}
onChange={handleChange}
placeholder="Email"
/>
<input
name="city"
value={profile.city}
onChange={handleChange}
placeholder="City"
/>
<p>
{profile.name} Β· {profile.email} Β· {profile.city}
</p>
</form>
);
}
[e.target.name] is computed property syntax, meaning it uses the input's name attribute as the property key. This means one handler can manage every field in the form, regardless of how many fields there are.
<aside> β οΈ
Warning
The spread operator is shallow: it only copies one level deep. If you have nested objects (an object inside an object), you'll need to spread at each level:
// β This mutates the nested address object
setUser({ ...user, address: user.address });
user.address.city = 'Utrecht'; // still mutating!
// β
Spread at each level you're changing
setUser({
...user,
address: {
...user.address,
city: 'Utrecht',
},
});
Deeply nested state is a signal that the state structure might need rethinking. Flat is easier.
</aside>
Arrays follow the same rule: never mutate, always replace. The React docs have a useful reference for which array methods to avoid and which to reach for:
| --- | --- | --- |
Adding an item:
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
function handleAdd() {
if (!input.trim()) return;
setTodos([
...todos,
{ id: Date.now(), text: input, done: false },
]);
setInput('');
}
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Add a task..."
/>
<button onClick={handleAdd}>Add</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
}
Removing an item β filter out the item you don't want:
function handleDelete(id) {
setTodos(todos.filter((todo) => todo.id !== id));
}
Updating an item β use map to return a new array where the matching item is replaced: