Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
20 minLesson 4 of 33
React Core Concepts

useState: State Management

useState: State Management

State is what makes React components dynamic. Without state, components are static — they render once and never change. With state, components respond to user interaction, API responses, and time-based changes.

useState is the hook you'll use in nearly every component you write.

What State Is (and What It Isn't)

State is data that:

  • Belongs to a specific component
  • Can change over time
  • Causes the component to re-render when it changes

Not state:

  • Data that never changes → use a constant or prop
  • Data derived from other state/props → calculate it during render
  • Data shared across many components → lift it up or use Context

Basic useState

import { useState } from 'react';

function Counter() {
    const [count, setCount] = useState(0);  // [currentValue, setterFunction]
    //                                  ^— initial value

    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>
    );
}

useState returns an array with exactly two items:

  1. The current state value
  2. A setter function that updates the value AND triggers a re-render

The Golden Rule: Never Mutate State

React detects state changes by reference equality. If you mutate the current value directly, React won't know it changed.

// WRONG — mutating state
const [user, setUser] = useState({ name: "Alice", age: 30 });

function birthday() {
    user.age++;          // Mutates the existing object
    setUser(user);       // React sees the same reference — no re-render!
}

// RIGHT — always create a new value
function birthday() {
    setUser({ ...user, age: user.age + 1 });  // New object = new reference
}

Same rule for arrays:

// WRONG
const [items, setItems] = useState(["a", "b", "c"]);
items.push("d");    // Mutates — won't trigger re-render

// RIGHT
setItems([...items, "d"]);     // Add item
setItems(items.filter(i => i !== "b"));   // Remove item
setItems(items.map(i => i === "a" ? "A" : i));  // Update item

Functional Updates

When new state depends on previous state, use a function:

// This can have race conditions with multiple rapid clicks
setCount(count + 1);

// This is always correct — uses the latest state
setCount(prevCount => prevCount + 1);

The function form guarantees you're working with the most recent state value:

function Counter() {
    const [count, setCount] = useState(0);

    function handleTripleIncrement() {
        // These all run before re-render — count is still 0 for all three
        setCount(count + 1);   // → 1
        setCount(count + 1);   // → 1 again! count was still 0
        setCount(count + 1);   // → 1 again!

        // Use functional update to correctly chain:
        setCount(prev => prev + 1);  // → 1
        setCount(prev => prev + 1);  // → 2
        setCount(prev => prev + 1);  // → 3
    }
}

State with Objects

function UserForm() {
    const [form, setForm] = useState({
        name: '',
        email: '',
        role: 'user',
    });

    function handleChange(field, value) {
        setForm(prev => ({
            ...prev,       // Keep all existing fields
            [field]: value // Update only the changed field
        }));
    }

    return (
        <form>
            <input
                value={form.name}
                onChange={e => handleChange('name', e.target.value)}
            />
            <input
                value={form.email}
                onChange={e => handleChange('email', e.target.value)}
            />
            <select
                value={form.role}
                onChange={e => handleChange('role', e.target.value)}
            >
                <option value="user">User</option>
                <option value="admin">Admin</option>
            </select>
        </form>
    );
}

Practical Example: Todo List

function TodoApp() {
    const [todos, setTodos] = useState([]);
    const [input, setInput] = useState('');

    function addTodo() {
        if (!input.trim()) return;
        setTodos(prev => [
            ...prev,
            { id: Date.now(), text: input.trim(), completed: false }
        ]);
        setInput('');
    }

    function toggleTodo(id) {
        setTodos(prev =>
            prev.map(todo =>
                todo.id === id ? { ...todo, completed: !todo.completed } : todo
            )
        );
    }

    function deleteTodo(id) {
        setTodos(prev => prev.filter(todo => todo.id !== id));
    }

    return (
        <div>
            <input
                value={input}
                onChange={e => setInput(e.target.value)}
                onKeyDown={e => e.key === 'Enter' && addTodo()}
                placeholder="Add a todo..."
            />
            <button onClick={addTodo}>Add</button>

            <ul>
                {todos.map(todo => (
                    <li key={todo.id}>
                        <span
                            onClick={() => toggleTodo(todo.id)}
                            style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
                        >
                            {todo.text}
                        </span>
                        <button onClick={() => deleteTodo(todo.id)}>×</button>
                    </li>
                ))}
            </ul>

            <p>{todos.filter(t => !t.completed).length} remaining</p>
        </div>
    );
}

Lazy Initial State

If the initial state is expensive to compute, pass a function:

// This runs getExpensiveValue() on every render — wasteful
const [data, setData] = useState(getExpensiveValue());

// This only runs once — React calls the function on mount only
const [data, setData] = useState(() => getExpensiveValue());

// Common use: read from localStorage
const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') ?? 'light';
});

Common useState Mistakes

// Mistake 1: State too low — should be lifted
// ❌ Both siblings need the count — don't store it in only one
function Parent() {
    return (
        <>
            <CounterDisplay />  {/* Can't see the count */}
            <CounterButton />   {/* Has the count state */}
        </>
    );
}

// ✅ Lift state to parent
function Parent() {
    const [count, setCount] = useState(0);
    return (
        <>
            <CounterDisplay count={count} />
            <CounterButton onIncrement={() => setCount(c => c + 1)} />
        </>
    );
}

// Mistake 2: Redundant state (derived data)
// ❌ Don't store what you can calculate
const [firstName, setFirstName] = useState('Alice');
const [lastName, setLastName] = useState('Smith');
const [fullName, setFullName] = useState('Alice Smith');  // Redundant!

// ✅ Calculate it
const fullName = `${firstName} ${lastName}`;  // No state needed

Next lesson: useEffect — managing side effects like data fetching, subscriptions, and timers.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!