useState Patterns — Primitives, Objects and Arrays

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)

🧠 Test Yourself

You have const [post, setPost] = useState({ title: "Hello", body: "World" }). You call setPost({ title: "New Title" }). What is post.body after the update?