The useState hook is how function components manage local state. It replaces the this.state and this.setState of class components with a cleaner, function-based API. Every interactive feature in the MERN Blog — loading indicators, form inputs, modal open/closed, toggle buttons, pagination — uses useState at its core. In this lesson you will master the complete useState API: declaring state, updating primitives and objects and arrays correctly, and understanding the asynchronous nature of state updates.
useState Syntax
import { useState } from 'react';
// useState returns a tuple: [currentValue, setterFunction]
const [count, setCount] = useState(0);
// ↑ ↑ ↑
// current setter fn initial value
// Naming convention: [noun, setNoun]
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
useState(0) is fine even though the expression is “evaluated” on every render — React discards the result after the first mount.setCount(count + 1), the component does not re-render immediately — React schedules the update. If you call the setter multiple times in the same event handler, React batches them and applies all updates in a single re-render. This is why reading state immediately after setting it returns the old value. Use the functional updater form (setCount(prev => prev + 1)) when the new value depends on the previous value.Updating Primitive State
function LikeButton() {
const [count, setCount] = useState(0);
const [liked, setLiked] = useState(false);
const [message, setMessage] = useState('');
// ── Direct value ──────────────────────────────────────────────────────────
const reset = () => setCount(0);
const disable = () => setLiked(false);
// ── Value based on previous — use functional updater ──────────────────────
const increment = () => setCount(prev => prev + 1); // safe: reads latest value
const toggle = () => setLiked(prev => !prev); // safe: reads latest value
// ── Why functional updater matters ────────────────────────────────────────
// Batching problem: if React batches these, count + 1 reads stale value twice
setCount(count + 1); // ← count is still 0 from this render
setCount(count + 1); // ← count is still 0 from this render → final: 1 not 2
// Functional updater: each call receives the latest queued value
setCount(prev => prev + 1); // prev = 0 → queues 1
setCount(prev => prev + 1); // prev = 1 → queues 2 → final: 2 ✓
return <button onClick={toggle}>{liked ? '❤️' : '🤍'} {count}</button>;
}
Updating Object State — Immutable Patterns
function PostEditor() {
const [post, setPost] = useState({
title: '',
body: '',
excerpt: '',
tags: [],
published: false,
});
// ── Update one field — spread the rest ────────────────────────────────────
// NEVER: post.title = 'New Title'; setPost(post) — mutates state directly!
const updateTitle = (newTitle) => {
setPost(prev => ({ ...prev, title: newTitle }));
};
// ── Update nested field ───────────────────────────────────────────────────
// If post had nested: post.meta.keywords
const updateKeywords = (keywords) => {
setPost(prev => ({
...prev,
meta: { ...prev.meta, keywords }, // spread inner object too
}));
};
// ── Generic field updater for form inputs ─────────────────────────────────
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setPost(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
return (
<form>
<input name="title" value={post.title} onChange={handleChange} />
<textarea name="body" value={post.body} onChange={handleChange} />
<input name="published" type="checkbox"
checked={post.published} onChange={handleChange} />
</form>
);
}
Updating Array State — Immutable Patterns
function TagManager() {
const [tags, setTags] = useState(['mern', 'react']);
// ── Add an item ────────────────────────────────────────────────────────────
const addTag = (newTag) => {
if (!tags.includes(newTag)) {
setTags(prev => [...prev, newTag]); // spread + append — does NOT mutate prev
}
};
// ── Remove an item ─────────────────────────────────────────────────────────
const removeTag = (tagToRemove) => {
setTags(prev => prev.filter(t => t !== tagToRemove));
};
// ── Update an item at a specific index ─────────────────────────────────────
const updateTag = (index, newValue) => {
setTags(prev => prev.map((t, i) => i === index ? newValue : t));
};
// ── Sort without mutating ─────────────────────────────────────────────────
const sortTags = () => {
setTags(prev => [...prev].sort()); // [...prev] creates a copy before sorting
};
return (
<div>
{tags.map((tag, i) => (
<span key={tag}>
#{tag}
<button onClick={() => removeTag(tag)}>×</button>
</span>
))}
</div>
);
}
Multiple State Variables vs One Object
// ── Option A: Multiple separate state variables (recommended for unrelated data)
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
// ── Option B: One object (for tightly related data that updates together)
const [fetchState, setFetchState] = useState({
data: [],
loading: false,
error: null,
});
// Updating one field — must spread the rest
setFetchState(prev => ({ ...prev, loading: true }));
// Rule of thumb:
// ✓ Separate variables: when values are independent and often updated separately
// ✓ One object: when values always change together (like loading + error + data)
Common Mistakes
Mistake 1 — Mutating state directly
❌ Wrong — mutating the array/object in place:
const [posts, setPosts] = useState([]);
// WRONG: push mutates the existing array — React does not detect the change
posts.push(newPost);
setPosts(posts); // same reference — React thinks nothing changed, no re-render!
✅ Correct — create a new array:
setPosts(prev => [...prev, newPost]); // new array reference → React re-renders ✓
Mistake 2 — Reading state immediately after setting it
❌ Wrong — state is not updated synchronously:
setCount(count + 1);
console.log(count); // still the OLD value — update is scheduled, not applied yet
✅ Correct — use the new value directly, or derive from the functional updater:
const newCount = count + 1;
setCount(newCount);
console.log(newCount); // the new value ✓
Mistake 3 — Not using functional updater for sequential updates
❌ Wrong — multiple setters in the same handler using the same stale value:
// If called twice rapidly (e.g. double-click):
setLikeCount(likeCount + 1); // likeCount = 5 → schedules 6
setLikeCount(likeCount + 1); // likeCount = 5 (still!) → schedules 6 again → final: 6 not 7
✅ Correct — functional updater always reads the latest queued value:
setLikeCount(prev => prev + 1); // prev = 5 → 6
setLikeCount(prev => prev + 1); // prev = 6 → 7 ✓
Quick Reference
| Task | Code |
|---|---|
| Declare state | const [value, setValue] = useState(initialValue) |
| Update primitive | setValue(newValue) |
| Update based on previous | setValue(prev => prev + 1) |
| Toggle boolean | setFlag(prev => !prev) |
| Update object field | setObj(prev => ({ ...prev, field: val })) |
| Add to array | setArr(prev => [...prev, item]) |
| Remove from array | setArr(prev => prev.filter(i => i !== item)) |
| Update item in array | setArr(prev => prev.map((i, idx) => idx === n ? newVal : i)) |
| Reset to initial | setValue(initialValue) |