You click a button. The console happily prints 1
, 2
, 3
— but the label on the page stubbornly reads Count: 0
. Infuriating? Absolutely. Confusing? Totally!!!. That mismatch is exactly what this article fixes.
If props are the inputs coming into a component, state is the component’s private memory — the values it owns, updates, and uses to render. Change the memory the right way and React redraws the UI. Mutate a local variable instead, and React doesn’t care.
This article is part of our React 19 Deep Dive Series. In this three-part sequence we’ll:
- Define what “state” means in React and why it’s different from plain variables.
- Walk through
useState
basics. - Cover updater functions, stale closures, lifting state up, controlled vs uncontrolled inputs
- Finish with pitfalls, performance, and how
useState
fits into React 19
Not for the faint of heart — deep dive ahead (≈15 minutes). Proceed if you want to get into the weeds
Quick, 8-minute primer from our React Crash Course — a quick live-coding demo that’s useful as a primer only; it doesn’t go in-depth. Read the full deep dive below for the detailed explanations and edge cases.
This article serves both as a beginner's guide, as well as a reference for later use for usage guide/troubleshooting. It would use some concepts not introduced yet, but they would be explained later with dedicated articles later in the series.
Table of contents
Part 1 — Understanding State
Part 2 — Updating State Safely
Part 3 — Rules, Pitfalls & Performance
What Is “State” in React?
State is:
- Data that belongs to a specific component.
- Data that can change during the component’s lifetime.
- Data that, when changed, causes the component to re-render.
Think of a component as a whiteboard. Props are the sticky notes other people pin there — they come from outside and you read them as-is. State is what you write on the board — your own notes that can change while you work, whether you update them directly or they change in response to something happening (like a button click, a timer, or data arriving).
State vs Regular Variables — the “Why” (with a tiny demo)
You might ask: “Why not just use a let
inside the component?” Fair question. At first glance, you might think “If I change a variable, the UI should change too.”
That’s true in vanilla JS if you manually update the DOM yourself — but in React, the UI only changes when the component re-renders. And React re-renders only when state or props change, not regular variables. Here’s a tiny example:
Regular variable example (won’t update UI):
function Counter() {
let count = 0;
function increment() {
count++;
console.log(count);
}
return <button onClick={increment}>Count: {count}</button>;
}
Clicking logs numbers to the console, but the button label never changes. React doesn’t re-render because it only tracks state and props.
Quick definition — what “re-render” means:
A re-render means React runs the component function again, figures out what the UI should look like now, diffs that against the current DOM, and then updates the DOM where necessary.
useState
example (UI updates):
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
function increment() {
// Using the functional updater is a good habit:
setCount(prev => prev + 1);
}
return <button onClick={increment}>Count: {count}</button>;
}
setCount
tells React “this component’s data changed — please re-render.” You get the visible update and the predictable behavior you expect.
Key insight: React re-renders when state or props change — not when arbitrary local variables mutate.
useState
— the basic pattern
useState
lets function components hold onto values across renders.
Syntax
const [value, setValue] = useState(initialValue);
-
value
— the current state. -
setValue
— enqueues a state update and triggers a re-render when appropriate. -
initialValue
— the starting value for the first render (can be a value or a lazy initializer).
Simple example
const [isDarkMode, setIsDarkMode] = useState(false);
Calling setIsDarkMode(true)
schedules a re-render where isDarkMode === true
.
Lazy initializers (when the initial setup is expensive)
If computing the initial state is costly, pass a function to useState
so the work runs only once:
function expensiveInit() {
// heavy work
return computeBigInitialValue();
}
const [data, setData] = useState(() => expensiveInit());
Practical example: reading from localStorage
only once on mount
const [settings, setSettings] = useState(() =>
JSON.parse(localStorage.getItem('settings') || '{}')
);
Using the function form prevents localStorage
reads on every render — useful when the initial value touches I/O or expensive computation.
Initializing state from props — the common pitfall & pattern
A common trap is this:
const [value, setValue] = useState(props.initialValue);
That initialValue
is only used on mount. It does not automatically track later prop changes.
If you need the local state to follow prop updates, do it explicitly:
function Component({ initialValue }) {
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
// ...
}
Rule of thumb: only sync props into state when the component needs a local, editable copy (for example, a form input that starts from a prop). If you just need to reflect the prop, keep props as the single source of truth.
Here’s the merged and tightened version that removes the repetition while keeping all the important ideas, examples, and batching context:
Updating State: Direct Value vs Updater Function
When you update state, you can pass either a direct value or a function (called an updater function) to the setter returned by useState
.
Direct value — replaces the state with a new value:
setCount(count + 1);
Updater function — receives the latest committed state and returns the new value:
setCount(prev => prev + 1);
When to use which
-
Direct value — fine when the new value does not depend on the old one (e.g.,
setTheme('dark')
). - Updater function — always use this when the new value depends on the previous state. This avoids subtle bugs, especially because React may batch multiple updates before re-rendering.
Many React developers learn this the hard way. Let’s save you some retries.
Batching & “are state updates synchronous?”
React batches multiple state updates that happen in the same event or async callback to improve performance. This means updates don’t happen immediately — they’re scheduled and applied together just before React re-renders.
Example that surprises beginners:
function Example() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
console.log(count); // Logs the old value — updates are still pending
}
return <button onClick={handleClick}>{count}</button>;
}
Both calls above read the same stale count
value from the current render, so the state only increments once.
Fix: use the updater form so each call works with the latest committed state — even during batching:
setCount(prev => prev + 1);
setCount(prev => prev + 1);
Now both increments apply correctly because each updater runs against the newest state snapshot React has committed.
Why batching changes the outcome
React batches multiple state updates that happen in the same synchronous event (like a button click) into one re-render for performance.
This means your component function doesn’t run in between those updates — all the updates share the same render scope.
Direct value form — why it “fails” here
Render 1 starts: count = 0
↓
setCount(count + 1) // schedules: "set count to 1" (based on 0)
setCount(count + 1) // schedules: "set count to 1" (still based on 0)
↓
React batches updates → last one wins → count = 1
↓
Single re-render happens (starts from 1)
Problem: Both calls saw count = 0
because React didn’t re-run your component between them.
Updater function form — why it works
Render 1 starts: count = 0
↓
setCount(prev => prev + 1) // schedules: prev = 0 → 1
setCount(prev => prev + 1) // schedules: prev = 1 → 2
↓
React batches updates → applies them in sequence → count = 2
↓
Single re-render happens (starts from 2)
Fix: The updater function receives the latest committed value in the batch, so each update builds on the result of the last.
Key takeaway:
In a batched update, multiple
setState
calls share the same render’s variables. If you need each call to use the latest value, use the updater function.
Stale closures — the sneaky bug that looks random
When functions capture variables from an earlier render, they hold onto that old snapshot. That’s a stale closure. It often shows up with timers, subscriptions, or long-lived callbacks.
Broken example — a classic stale-closure timer
function TimerBroken() {
const [count, setCount] = useState(0);
function start() {
setInterval(() => {
// This callback *captures* `count` from the render when start() ran.
setCount(count + 1);
}, 1000);
}
return (
<>
<div>Count: {count}</div>
<button onClick={start}>Start</button>
</>
);
}
What happens: the interval callback keeps using the old count
value it captured, so the counter behavior is wrong (or freezes). This looks random because it depends on when start()
was called relative to renders.
Safer patterns — avoid stale closures
- Use the functional updater inside callbacks so you always operate on the latest state:
setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
- Store the interval id in a ref and clean it up so you don’t leak timers across mounts/unmounts.
Don't worry about what ref is at the moment, it would be fully explained in later article. For now just read through it at ease. It's primarily for back reference when you need it :)
Correct, robust Timer
import { useState, useRef, useEffect } from 'react';
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
function start() {
if (intervalRef.current) return; // already running
intervalRef.current = setInterval(() => {
setCount(prev => prev + 1); // functional updater avoids stale closures
}, 1000);
}
function stop() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
useEffect(() => {
return () => {
// cleanup on unmount
clearInterval(intervalRef.current);
};
}, []);
return (
<>
<div>Count: {count}</div>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</>
);
}
Why this is good:
-
useRef
holds the interval id without causing re-renders. -
setCount(prev => prev + 1)
always uses the latest state. -
useEffect
cleanup prevents leaks if the component unmounts.
I would highly encourage you to watch this short video where I explain closures concept properly. (Note channel has dedicated React debounce video as well but this one explains concept of closure properly)
Lifting state up — sharing data cleanly
Imagine you have two volume sliders in your app — one in the header, one in the footer. They’re supposed to control the same volume level.
If each slider keeps its own useState
, they’ll quickly get out of sync: moving the header slider won’t move the footer one, and vice versa.
The fix is to lift the state up — move that volume
value into the nearest common ancestor of both sliders. That ancestor becomes the single source of truth:
- It owns the actual state.
- It passes the value down to each slider via props.
- It passes an update function down so sliders can request changes.
Example — two sliders, one volume level
Parent (state owner):
function App() {
const [volume, setVolume] = useState(50);
return (
<>
<VolumeSlider value={volume} onChange={setVolume} />
<VolumeSlider value={volume} onChange={setVolume} />
</>
);
}
Child (controlled by parent):
function VolumeSlider({ value, onChange }) {
return (
<input
type="range"
min="0"
max="100"
value={value}
onChange={e => onChange(Number(e.target.value))}
/>
);
}
How it works
-
One owner:
App
holds thevolume
state — there’s no duplication. -
Props down: Both
VolumeSlider
components readvalue
fromApp
. -
Callbacks up: When a slider changes, it calls
onChange
, which updatesApp
’s state. - Always in sync: Updating the parent’s state re-renders both sliders with the new volume.
Why it matters:
This “data down, actions up” approach makes synchronization predictable and avoids subtle bugs from mismatched state.
This keeps a single source of truth (Parent
) and avoids duplicated state. If later you need to minimize re-renders, you can memoize Counter
or use callbacks (with care — only when necessary). (And yes this will be covered later in the series)
Controlled vs Uncontrolled inputs — pick the right tool
When working with form inputs in React, you have two main ways to manage their values:
- Controlled — React keeps the value in state.
- Uncontrolled — The DOM keeps the value, and you read it only when needed.
Which you choose affects how you handle validation, synchronization, and performance.
Controlled inputs — React owns the value
In a controlled input, the form element’s value is driven entirely by React state. Every change triggers an onChange
event, which updates state, and that state sets the value
prop.
function NameForm() {
const [name, setName] = useState('');
return (
<input
value={name}
onChange={e => setName(e.target.value)}
/>
);
}
Pros:
- Single source of truth in React.
- Easy to validate and transform input as the user types.
- Other components can react instantly to changes.
Cons:
- Can cause more re-renders for very fast, continuous input (usually negligible).
Uncontrolled inputs — the DOM owns the value
In an uncontrolled input, you let the DOM manage the element’s value. React does not track it in state. You read it only when necessary, usually via a ref
.
import { useRef } from 'react';
function NameFormUncontrolled() {
const inputRef = useRef();
function handleSubmit() {
const value = inputRef.current.value;
// use value
}
return (
<>
<input defaultValue="start" ref={inputRef} />
<button onClick={handleSubmit}>Submit</button>
</>
);
}
Pros:
- Simpler for quick, standalone form fields.
- Fewer re-renders during typing.
Cons:
- Harder to validate or modify while typing.
- State in the DOM may fall out of sync with other UI state.
When to use which input type
- Use controlled inputs when you need live validation, dependent UI updates, or consistent state across components.
- Use uncontrolled inputs for quick, isolated form fields where you only care about the value at submit time.
Object identity & Object.is
— why copies matter (concrete example)
React compares new and previous state for equality using an Object.is
-like check. That’s why object/array updates usually mean creating a new reference.
Concrete examples
Object.is(1, 1); // true
Object.is('a', 'a'); // true
const o = {};
Object.is(o, o); // true
Object.is({}, {}); // false — two distinct empty objects
So:
// This does NOT trigger a re-render if `obj` is the same reference:
setObj(obj);
// But this WILL (new reference):
setObj(prev => ({ ...prev, x: 1 }));
When you intend to change an object or array stored in state, return a new object/array so React sees a different reference and can re-render.
State Management — Rules You’ll Actually Use
Think of these as your React state “house rules” — simple to remember, hard to regret:
Never set state during render
CallingsetValue(...)
directly in the component body will cause infinite loops.
➜ Use event handlers, effects, or callbacks instead.Use functional updaters when new state depends on old
Example:setCount(prev => prev + 1)
avoids batching and stale closure issues.Avoid stale closures
Long-lived callbacks (timers, subscriptions) should use updaters, refs, and cleanup functions inuseEffect
.Lift state up when multiple components need it
Keep a single source of truth in the nearest common ancestor; pass data down and events up.Prefer controlled inputs for most forms
They make validation, syncing, and UI updates predictable. Use uncontrolled inputs only for isolated, simple fields.Always create new references for objects/arrays
React detects changes usingObject.is
. Mutating in place won’t trigger a re-render.
Pitfalls to Watch For (Real Bugs You’ll See)
These are not theoretical — they happen to everyone at some point.
1. Over-storing state (too many bits)
Don’t store data that can be derived from props or other state — compute it instead.
❌ Unnecessary state:
const [total, setTotal] = useState(
items.reduce((s, i) => s + i.price, 0)
);
✅ Better:
const total = items.reduce((s, i) => s + i.price, 0);
2. Too coarse vs too fine granularity
- Too coarse: One giant state object → unrelated updates trigger unnecessary re-renders.
- Too fine: Dozens of tiny states → noisy code. Rule: Group values that change together; split ones that change independently.
✅ Example — split when updates are independent:
const [name, setName] = useState('');
const [email, setEmail] = useState('');
3. Overusing useMemo
/ useCallback
They’re performance tools, not lucky charms. Use them after you’ve identified slow renders, not preemptively.
Clear, maintainable code beats micro-optimizations.
4 Expensive render due to derived computation
Problem:
function App({ items }) {
const total = items.reduce((s, i) => s + i.price, 0); // expensive each render
return <BigList items={items} total={total} />;
}
Fix: memoize the expensive compute (only if profiling shows it matters):
const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]);
Performance — Practical Wins
Avoid needless re-renders
- Lift state to the nearest common ancestor that needs it.
-
Memoize pure child components with
React.memo
when props rarely change. - Keep state local-ish — don’t dump everything into top-level context unless the whole tree needs it.
✅ Example with React.memo
:
const Child = React.memo(function Child({ value }) {
return <div>{value}</div>;
});
This structure gives readers:
- One rule section (quick reference, minimal repetition).
- Pitfalls section (concrete examples for the most common mistakes).
- Performance tips (only once, no duplicates from rules).
Measure before optimizing
Use the React DevTools Profiler to find hot paths. Optimize only what matters.
Use useReducer
for complex state transitions
Just like useRef, useReducer has its own dedicated article later in the series.
When updates are many, interdependent, or you need clearer update logic, prefer useReducer
. It centralizes transitions and can make code easier to test.
const [state, dispatch] = useReducer(reducer, initialState);
Wrap-up checklist (what to ship)
- Prefer
useState
for local UI state (toggles, input values, small counters). - Use updater functions (
setCount(prev => …)
) whenever new state depends on previous state. - Avoid storing derived values — compute or memoize them.
- Lift state up for shared data; split state when independent updates cause many renders.
- Use
useReducer
for complex transitions andReact.memo
for stable child components.
Final thought
useState
is simple but subtle. Treat it like a Swiss Army knife: perfect for a lot of daily UI tasks, but know when you need a screwdriver (useReducer
), a file (useMemo
), or a magnifier (profiler). React 19 adds nicer tools around the edges — optimistic updates, actions, and better coordination — but the basic principles (state → render, identity matters, cleanup matters) stay the same. It would all make sense as you progress through the series.
Next up
Remember Virtual DOM, React's secret weapon for optimal performance? Now you have all the concepts to go over how React renders the UI, and how state plays it's role in the process. The next article stiches it all together.
Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Want video demos? Subscribe on YouTube: @LearnAwesome
Top comments (0)