While useState works the same way for all data types, objects and arrays need special care — you must always create a new object or array when updating, never mutate the existing one. React detects changes by reference equality: if you pass the same array reference to setState, React skips the re-render because it thinks nothing changed. Immutable update patterns — spread operators, filter, map — ensure every update produces a new reference and triggers the expected re-render.
Primitive State
import { useState } from "react";
function PostControls() {
const [title, setTitle] = useState("");
const [viewCount, setViewCount] = useState(0);
const [isEditing, setIsEditing] = useState(false);
return (
<div>
{/* String */}
<input value={title} onChange={(e) => setTitle(e.target.value)} />
{/* Number — functional form when next value depends on previous */}
<button onClick={() => setViewCount((v) => v + 1)}>
{viewCount} views
</button>
{/* Boolean — toggle with functional form */}
<button onClick={() => setIsEditing((prev) => !prev)}>
{isEditing ? "Cancel" : "Edit"}
</button>
</div>
);
}
Note: Split state into multiple independent
useState calls when the values change independently. One useState({ title, viewCount, isEditing }) means every button click that changes isEditing also re-creates the entire object (with spread), and all three values are bundled together even when only one changes. Separate state variables are simpler to update and easier to read. Use a single object state when multiple values always change together (like a form with many fields).Tip: Use a lazy initialiser for expensive initial values:
useState(() => JSON.parse(localStorage.getItem("drafts")) ?? []). The function is only called once during the initial render — not on every re-render. Without the function wrapper, JSON.parse(localStorage.getItem("drafts")) would run on every render (the result is just thrown away after the first render, but the call still happens). Pass a function, not a value, for expensive initialisations.Warning: Never mutate state directly.
state.title = "new" changes the object in memory but React does not detect the change — the state reference is the same object. The component will not re-render, and on the next re-render triggered by something else, the mutation may cause confusing behaviour because React’s internal copy of state does not match what you mutated. Always produce a new object: setState({ ...state, title: "new" }).Object State — Spread to Update
function PostEditor() {
const [post, setPost] = useState({
title: "",
body: "",
status: "draft",
tags: [],
});
// ✓ Spread to preserve unmodified fields
function updateTitle(newTitle) {
setPost({ ...post, title: newTitle });
// Creates new object: { title: newTitle, body: post.body, status: post.status, ... }
}
// ✓ Multiple fields at once
function publishPost() {
setPost({ ...post, status: "published" });
}
// ❌ Wrong — mutates existing state object
function badUpdate(newTitle) {
post.title = newTitle; // mutation — React won't re-render!
setPost(post); // same reference — React skips update
}
return (
<div>
<input
value={post.title}
onChange={(e) => setPost({ ...post, title: e.target.value })}
/>
<textarea
value={post.body}
onChange={(e) => setPost({ ...post, body: e.target.value })}
/>
<button onClick={publishPost}>Publish</button>
</div>
);
}
Array State — Immutable Patterns
function TagManager() {
const [tags, setTags] = useState(["python", "fastapi"]);
const [input, setInput] = useState("");
// Add a tag
function addTag() {
if (!input.trim() || tags.includes(input.trim())) return;
setTags([...tags, input.trim()]); // new array
setInput("");
}
// Remove a tag
function removeTag(tagToRemove) {
setTags(tags.filter((t) => t !== tagToRemove)); // new array
}
// Update a tag (rename)
function renameTag(old, newName) {
setTags(tags.map((t) => (t === old ? newName : t))); // new array
}
// Reorder (move tag up)
function moveUp(index) {
if (index === 0) return;
const newTags = [...tags]; // copy first — splice mutates!
[newTags[index - 1], newTags[index]] = [newTags[index], newTags[index - 1]];
setTags(newTags);
}
return (
<div>
{tags.map((tag, i) => (
<span key={tag}>
{tag}
<button onClick={() => removeTag(tag)}>✕</button>
<button onClick={() => moveUp(i)}>↑</button>
</span>
))}
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTag}>Add Tag</button>
</div>
);
}
When to Use One Object vs Multiple Primitives
| Scenario | Recommendation | Reason |
|---|---|---|
| Form with many fields | One object | Fields reset/submit together; single spread update |
| UI toggles (isOpen, isLoading) | Separate booleans | Each changes independently; simpler updates |
| Counters (likes, views) | Separate numbers | Each changes independently |
| Tightly coupled values | One object | Values that always change together (position: {“{x, y}”}) |
Common Mistakes
Mistake 1 — Forgetting to spread when updating object state
❌ Wrong — replaces entire state with just the new field:
setPost({ title: e.target.value });
// post.body and post.status are now GONE!
✅ Correct — spread first:
setPost({ ...post, title: e.target.value }); // ✓
Mistake 2 — Using sort() or splice() on state arrays (mutation)
❌ Wrong — sort and splice mutate in place:
tags.sort(); // mutates the array
setTags(tags); // same reference — no re-render!
✅ Correct — copy first:
setTags([...tags].sort()); // ✓ new sorted array
Quick Reference — Immutable Update Cheat Sheet
| Operation | Immutable Pattern |
|---|---|
| Update object field | setState({ ...state, field: newValue }) |
| Add array item | setState([...arr, newItem]) |
| Remove array item | setState(arr.filter(x => x.id !== id)) |
| Update array item | setState(arr.map(x => x.id===id ? {...x, ...changes} : x)) |
| Sort array | setState([...arr].sort(compareFn)) |
| Toggle boolean | setState(prev => !prev) |