Why a Normal Variable Cannot Work
Start with the version everyone writes first. Declare let count = 0 inside the component, increment it in a click handler, and display it. Click the button and the number on screen never moves. There is no error and the handler definitely runs — a console.log proves it.
Two things are going wrong at once. First, React has no idea the variable changed. Nothing in JavaScript notifies React when a plain variable is reassigned, so React never re-runs the component and never updates the page. Second, even if something did trigger a render, count is a local variable inside the function. Running the function again creates a brand-new count initialised to 0, and the incremented value is gone.
So a component needs a kind of variable with two extra powers: it must survive between renders, and changing it must tell React to re-render. That is exactly what useState provides, and it is why state is not an optional convenience — it is the only way to have a value that both persists and shows up on screen.
// BROKEN: nothing on screen ever changes
function Counter() {
let count = 0;
function handleClick() {
count = count + 1;
console.log(count); // 1, 2, 3... the variable really is changing
}
return (
<div>
<p>Count: {count}</p> {/* always 0 */}
<button onClick={handleClick}>+</button>
</div>
);
}
// The two problems:
// 1. Changing a plain variable does not tell React to re-render.
// 2. If it did re-render, the function would run again and reset count to 0. - The same logic applies to a variable declared outside the component. It would survive re-renders, but changing it still does not trigger one, and every instance of the component would share the same value.
useState: Value and Setter
useState is a hook — a function React provides that lets a component tap into React's own machinery. Call it with the initial value and it returns an array of exactly two things: the current value, and a function that updates it. Array destructuring gives them names, and the convention is [thing, setThing].
Calling the setter does two jobs. It stores the new value where React keeps it, outside your function, so it survives the next render. And it tells React that this component's output is now out of date, so React re-runs the function. On that second run, useState hands back the stored value instead of the initial one. That is why the initial argument only matters on the very first render.
State belongs to one instance of a component, not to the component definition. Render three <Counter /> components and you get three independent counts. This is what makes components genuinely reusable — a search box or an accordion carries its own state wherever you drop it, with no naming collisions to manage.
If your initial value takes real work to compute — reading and parsing localStorage, for instance — pass a function instead of a value. React calls it only on the first render. Passing the computed value directly means the work happens on every single render and is then thrown away.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>−</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// Three independent counters from one definition
function App() {
return (
<>
<Counter />
<Counter />
<Counter />
</>
);
}
// Expensive initial value? Pass a function — React runs it once.
const [draft, setDraft] = useState(() => JSON.parse(localStorage.getItem('draft')) ?? '');
// NOT this: JSON.parse runs on every render and the result is discarded
// const [draft, setDraft] = useState(JSON.parse(localStorage.getItem('draft')) ?? ''); useState(initial)returns[value, setValue]- The initial value is used on the first render only
- Calling
setValuestores the value and schedules a re-render - Each instance of a component has its own separate state
- Use several
useStatecalls for several unrelated pieces of state - Pass a function as the initial value when computing it is expensive
- Hooks must be called at the top level of your component — never inside an
if, a loop, or a nested function. React tracks which state belongs to whichuseStatecall by the order the calls happen, so the order has to be identical on every render. The Custom Hooks lesson explains this in full.
Setting State Does Not Change It Immediately
This catches everyone exactly once, and it is worth understanding rather than memorising. Call setCount(count + 1) and then log count on the next line, and you will see the old value. It looks like the setter failed.
It did not. count is a const belonging to this run of the component function, and nothing can reassign it. What the setter does is tell React to schedule a re-render; the new value appears in a fresh run of the function, in a new count variable. React also batches updates, processing several state changes queued during the same event together before rendering once, which is why you cannot rely on anything happening between the two lines.
The practical rule is simple: if you need the new value inside the same function, use the expression you just computed, not the state variable. If you need to react to a value having changed, that is what effects are for, and they get their own lesson.
function SearchBox() {
const [query, setQuery] = useState('');
function handleChange(e) {
const next = e.target.value;
setQuery(next);
console.log(query); // the OLD value — always one keystroke behind
// Need the new value here? Use the variable you already have.
console.log(next); // correct
search(next); // correct
// search(query); // wrong: sends the previous query
}
return <input value={query} onChange={handleChange} />;
} - If you set state to a value identical to the one it already holds, React can skip the re-render entirely. So calling
setQuery(query)is not a way to force an update — and forcing an update is almost always a sign that some value should be state and is not.
The Updater Function, and Stale Values
The setter accepts a second form: instead of a value, pass a function that receives the pending value and returns the next one. setCount(c => c + 1) reads as whatever the count is by the time this runs, add one. Compare that with setCount(count + 1), which means set it to this specific number I calculated a moment ago.
Most of the time both work. They differ whenever the value you captured might already be out of date. The clearest case is calling the setter twice in one handler: setCount(count + 1); setCount(count + 1); with count at 5 queues set to 6 twice, and you end up at 6, not 7. Written as setCount(c => c + 1) twice, React applies each function to the result of the last, and you get 7.
The subtler case is a stale closure. A function created during one render permanently captures the variables of that render — that is how closures work in JavaScript, and React does not change it. So a callback stored in a setTimeout, an interval, an event listener or a promise .then may run much later while still holding the value from the render that created it. Reading count there gives you a number that was correct minutes ago. The updater form sidesteps the whole problem, because it does not read the captured variable at all.
A reasonable habit: when the next value depends on the previous one, use the updater form. When you are setting a value that has nothing to do with the old one — a name from an input, a fetched list — pass the value directly.
function Counter() {
const [count, setCount] = useState(0);
function addTwoWrong() {
setCount(count + 1); // count is 5 -> queue "set to 6"
setCount(count + 1); // count is STILL 5 here -> queue "set to 6" again
} // result: 6
function addTwoRight() {
setCount(c => c + 1); // 5 -> 6
setCount(c => c + 1); // 6 -> 7
} // result: 7
// Stale closure: this interval is created once and keeps the count
// from that first render forever.
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // BROKEN: count is 0 in here, always. Stuck at 1.
}, 1000);
return () => clearInterval(id);
}, []);
// FIXED: the updater form never reads the captured variable
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
} - A counter that ticks to 1 and then stops is the signature of this bug. Whenever a setter runs inside a timer, an event listener, or a callback that fires later, prefer the updater form.
Replace State, Never Mutate It
Here is the single most common React bug after the missing key. You have a list in state, you push a new item onto it, you call the setter with the same array — and nothing appears on screen.
React decides whether state changed by comparing the old value with the new one by identity, the same check === performs. push modifies the array in place and returns its length; the array is still the very same array. So React compares the array with itself, concludes nothing changed, and skips the render. Your data is updated and your screen is not. The same applies to pop, splice, sort and reverse on arrays, and to assigning a field on an object held in state.
The fix is to produce a new value every time. For arrays that means spreading into a new one to add, filter to remove, and map to change one item. For objects it means spreading the old object and overriding the fields you want. These are not React APIs — they are plain JavaScript, and they all share the property of returning something new rather than editing something existing.
Nested data needs care, because a spread copies only one level. Updating user.address.city means creating a new user object and a new address object inside it. If you find yourself writing three levels of spreads regularly, that is a signal to flatten your state or split it into separate useState calls.
const [items, setItems] = useState(['Apple', 'Banana']);
// BROKEN — same array, so React sees no change
function addBroken(item) {
items.push(item);
setItems(items); // nothing re-renders
}
// ADD — a new array
setItems([...items, 'Cherry']);
// REMOVE — filter returns a new array
setItems(items.filter(i => i !== 'Banana'));
// CHANGE ONE — map returns a new array
setTodos(todos.map(t => (t.id === id ? { ...t, done: !t.done } : t)));
// INSERT AT A POSITION — slice, never splice
setItems([...items.slice(0, 2), 'Mango', ...items.slice(2)]);
// SORT — copy first, because sort() mutates
setItems([...items].sort());
// OBJECTS — spread the old one, override the field
const [user, setUser] = useState({ name: '', email: '' });
setUser({ ...user, name: 'Riya' });
// NESTED — every level you change needs its own copy
setUser({
...user,
address: { ...user.address, city: 'Indore' }
}); HTML
<pre id="out"></pre> CSS
#out { font-family: ui-monospace, Consolas, monospace; font-size: 13px; line-height: 1.6; } JavaScript
// No React needed. React decides "did this state change?" with Object.is,
// which is the same identity check === performs. Watch what push does to it.
var out = document.getElementById('out');
function log(line) { out.textContent += line + '\n'; }
// --- The mutating way -------------------------------------------
var oldItems = ['Apple', 'Banana'];
var newItems = oldItems; // this is NOT a copy, it is the same array
newItems.push('Cherry');
log('MUTATED with push():');
log(' old : ' + JSON.stringify(oldItems)); // Cherry is in here too!
log(' new : ' + JSON.stringify(newItems));
log(' Object.is(old, new) = ' + Object.is(oldItems, newItems));
log(' -> React would read this as "no change" and skip the render.');
log('');
// --- The React way ----------------------------------------------
var before = ['Apple', 'Banana'];
var after = [].concat(before, ['Cherry']); // same idea as [...before, 'Cherry']
log('COPIED with spread:');
log(' old : ' + JSON.stringify(before)); // untouched
log(' new : ' + JSON.stringify(after));
log(' Object.is(old, new) = ' + Object.is(before, after));
log(' -> a different array, so React re-renders.'); - Add —
[...items, newItem] - Remove —
items.filter(i => i.id !== id) - Update one —
items.map(i => i.id === id ? { ...i, done: true } : i) - Sort or reverse — copy first with
[...items] - Object field —
{ ...obj, field: value } - Avoid in state:
push,pop,shift,splice,sort,reverse, and direct assignment
- The symptom to recognise: the data is right when you log it, but the screen never updates. That is nearly always a mutation. If the update disappears when a re-render happens for some unrelated reason, it is definitely a mutation.
Do Not Store What You Can Calculate
New React developers put far too much into state. If a value can be worked out from other state or from props, calculate it during render and do not store it. Every extra piece of state is another thing that can fall out of step with the rest.
The example below is the classic version: a product list with a search box, where someone stores both the full list and the filtered list. Now every code path that changes products must remember to recompute filtered. Miss one — a delete, a refresh, a price edit — and the screen shows results that no longer exist. Computing filtered during render makes that impossible, and it is less code.
Ask of every useState: can I derive this? A total from a cart, a count from a list, whether a form is valid, whether the Submit button should be disabled, a formatted date — all of those are calculations, not state. If you later measure that a calculation is genuinely expensive, useMemo exists for exactly that case, and the performance lesson covers it. Reach for it after measuring, not before.
// BROKEN: two pieces of state that must be kept in sync by hand
function ProductList({ initial }) {
const [products, setProducts] = useState(initial);
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(initial); // duplicate truth
function handleSearch(e) {
setQuery(e.target.value);
setFiltered(products.filter(p => p.name.includes(e.target.value)));
}
function handleDelete(id) {
setProducts(products.filter(p => p.id !== id));
// forgot to update `filtered` — the deleted product stays on screen
}
// ...
}
// FIXED: one source of truth, everything else calculated during render
function ProductList({ initial }) {
const [products, setProducts] = useState(initial);
const [query, setQuery] = useState('');
const filtered = products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
const total = filtered.reduce((sum, p) => sum + p.price, 0);
function handleDelete(id) {
setProducts(products.filter(p => p.id !== id)); // filtered follows automatically
}
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<p>{filtered.length} products · ₹{total}</p>
{filtered.map(p => (
<ProductCard key={p.id} product={p} onDelete={handleDelete} />
))}
</>
);
} - A useful test: if two pieces of state must always be updated together to stay correct, one of them should not be state.
Lifting State Up
State lives in one component, but screens are made of many. When two sibling components need the same value — a size selector and a price display, a filter panel and a results list, a colour picker and a preview — neither can see the other's state. The solution is to move the state to their nearest common parent and pass it back down: the value as one prop, and a function to change it as another. This is called lifting state up.
The parent becomes the single owner of that value. Children receive it and report events, exactly as in the callback-prop pattern from the previous lesson. Nothing new is required — lifting state up is just the deliberate application of props plus useState.
Lift only as far as necessary. State placed higher than it needs to be causes every component below to re-render on every change, and it makes the top of your app a dumping ground for details that belong further down. The right home for a piece of state is the lowest component that contains everybody who needs it.
// The size buttons and the price label both depend on the chosen size,
// so the size lives in their common parent.
function ProductDetail({ product }) {
const [size, setSize] = useState('M');
const price = product.pricesBySize[size];
return (
<div>
<h2>{product.name}</h2>
<SizePicker sizes={product.sizes} selected={size} onSelect={setSize} />
<PriceLabel price={price} size={size} />
</div>
);
}
function SizePicker({ sizes, selected, onSelect }) {
return (
<div>
{sizes.map(s => (
<button
key={s}
className={s === selected ? 'active' : ''}
onClick={() => onSelect(s)}
>
{s}
</button>
))}
</div>
);
}
function PriceLabel({ price, size }) {
return <p>Size {size} — ₹{price}</p>;
} - Notice that
SizePickerandPriceLabelnow hold no state at all. Components that only receive props and render are the easiest kind to test, reuse and reason about, and most components in a well-organised app look like this.
