The Definitive Guide to Mastering the React useState HookA Comprehensive Guide

The Definitive Guide to Mastering the React useState Hook

Table of Contents

Welcome to the Definitive Guide to the React useState Hook. Whether you're a beginner who just finished a React course or a developer building real applications, this guide will take you from 'I kind of get it' to 'I can build anything with useState' through seven real-world projects, detailed breakdowns, and hands-on practice tasks. No filler — just the knowledge you need to write better React code.

Understanding useState Hook

Every interactive React app needs to remember things: how many items are in a cart, what a user typed into a search bar, whether a menu is open or closed. useState is how React remembers. It gives your component a piece of state — a value that persists between renders and, when updated, causes React to re-draw the screen with the new data.

Here is the simplest mental model: imagine a whiteboard next to your component. When the component first renders, you write a value on the whiteboard (the initial state). Every time the user does something that should change the display — clicking a button, typing in a field — you erase the old value and write the new one. React sees the whiteboard changed and re-renders the component to match. That's useState. The whiteboard is the state, the eraser is the setter function, and React is the person redrawing the UI.

Under the Hood

When a functional component renders, React maintains an internal list of hooks for that component. Each useState call reserves a slot in this list. On the first render, the slot is initialised with your value. On subsequent renders, React reads the existing value from that slot instead of re-initialising. This is why hooks must always be called in the same order — React matches each useState call to its slot by position, not by name. If you call useState inside an if-block, the position shifts and React assigns the wrong value to the wrong variable.

Syntax and Rules

jsx
import { useState } from 'react';

function MyComponent() {
  const [value, setValue] = useState(initialValue);
  // value:       the current state
  // setValue:     function to update state and trigger a re-render
  // initialValue: starting value (number, string, boolean, object, array, or function)
}

useState returns an array with exactly two elements. We use array destructuring to name them — the first is the current value, the second is the setter. You can name them anything, but the convention is [thing, setThing].

The Five Rules of useState
  • Call hooks at the top level only: Never inside if-statements, loops, or nested functions. React tracks hooks by their call order — changing the order between renders breaks the mapping between hook calls and their stored values.
  • Call hooks only in React functions: useState only works inside functional components or custom hooks. It cannot be used in regular JavaScript functions, class components, or event handlers defined outside the component.
  • State updates are asynchronous: Calling setState doesn't change the value instantly. React schedules the update and applies it on the next render. If you log the state right after calling setState, you'll see the old value.
  • Use the functional form for sequential updates: When computing new state from old state, always use setState(prev => prev + 1) instead of setState(state + 1). This guarantees you're working with the latest value, even when React batches multiple updates together.
  • Never mutate state directly: For objects and arrays, always create a new reference ({...obj, key: newValue} or [...arr, newItem]). React compares references with Object.is — if the reference hasn't changed, React skips the re-render entirely.
Lazy Initialisation

If your initial state requires an expensive computation (like parsing JSON from localStorage), pass a function instead of a value. React will only call this function once, on the first render:

jsx
// Bad: runs on EVERY render
const [data, setData] = useState(expensiveComputation());

// Good: runs ONLY on the first render
const [data, setData] = useState(() => expensiveComputation());
Automatic Batching (React 18+)

In React 18+, multiple setState calls within the same event handler are automatically batched into a single re-render. Calling setName('Alice') and setAge(30) in the same click handler triggers only one re-render, not two.

Re-render Bailout

React uses Object.is to compare previous and new state values. For primitives, if you call setState with the same value (e.g., setCount(5) when count is already 5), React skips the re-render entirely. For objects and arrays, React compares by reference — you must create a new object or array for React to detect the change.

Common Pitfalls

These mistakes trip up most developers. Understanding them now saves hours of debugging later.

1. Mutating State Directly
jsx
// WRONG: Same reference — React won't re-render
const [user, setUser] = useState({ name: 'Alice', age: 25 });
user.age = 26;
setUser(user);

// CORRECT: New reference — React detects the change
setUser({ ...user, age: 26 });
2. Stale Closures in Rapid Updates
javascript
// WRONG: All three read the same stale 'count' value
const handleTripleIncrement = () => {
  setCount(count + 1);  // 0 → 1
  setCount(count + 1);  // still reads 0 → 1
  setCount(count + 1);  // still reads 0 → 1
};

// CORRECT: Functional updates always read the latest value
const handleTripleIncrement = () => {
  setCount(prev => prev + 1);  // 0 → 1
  setCount(prev => prev + 1);  // 1 → 2
  setCount(prev => prev + 1);  // 2 → 3
};
3. Overusing useState

Not every value needs its own useState. If you can compute a value from existing state, just calculate it during render. For example, if you have items in state, the count is just items.length — you don't need a separate const [itemCount, setItemCount] = useState(0).

4. Object State vs Multiple States
jsx
// Option A: Related fields in one object
const [form, setForm] = useState({ name: '', email: '' });
// Update: setForm({ ...form, name: 'Alice' })

// Option B: Separate states
const [name, setName] = useState('');
const [email, setEmail] = useState('');

// Rule of thumb: Group fields that change together.
// Split fields that change independently.

Example 1: The Classic Counter

The counter is the 'Hello World' of useState. It teaches the core loop: state changes → UI updates.

What You'll Learn
  • Storing a number in state
  • Updating state from button clicks
  • How React re-renders when state changes
Full Code
jsx
import { useState } from 'react';

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

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>Add 1</button>
      <button onClick={() => setCount(count - 1)}>Subtract 1</button>
    </div>
  );
}
Step-by-Step Breakdown

const [count, setCount] = useState(0) — Creates a state variable called count, starting at 0. React stores this value internally. Every time you call setCount with a new value, React re-runs this function and the JSX reflects the new count.

onClick={() => setCount(count + 1)} — When the user clicks 'Add 1', this calls setCount with the current count plus one. React schedules a re-render, re-executes Counter, and useState(0) now returns 1 (React ignores the initial value after the first render). The <h1> displays 'Count: 1'.

What Would Break
  • Use let count = 0 instead of useState → The variable resets to 0 on every render. State is the only way to persist values between renders.
  • Write count++ instead of setCount(count + 1) → Mutating the variable directly doesn't tell React anything changed. The UI won't update.
  • Call setCount inside the component body (not in an event handler) → Creates an infinite loop: render → setState → re-render → setState → crash.
Practice Tasks

Task 1: Build a counter with a customisable step value. The user picks a step size (1, 5, or 10) using buttons, then uses Add/Subtract buttons to change the count by that step.

Hint: You need two states — one for count and one for step. The Add button calls setCount(count + step).

Try It Yourself

Task 2: Create a colour changer. Buttons cycle through colours (red, blue, green, yellow). Display the colour name and apply it as the background of a div.

Hint: Store a colour string in state. Each button calls setColor with its colour.

Try It Yourself

Task 3: Build a name greeting component. An input field stores the user's name. Below it, display 'Hello, [name]!' that updates live as they type.

Hint: Use useState('') for the name. The input's onChange updates state with e.target.value.

Try It Yourself

Example 2: Toggle Switch — Mastering Booleans

Booleans are everywhere: dark/light mode, menu open/closed, checkbox state. This teaches binary state and conditional rendering.

What You'll Learn
  • Storing a boolean in state
  • Toggling with the NOT operator (!)
  • Conditional rendering with ternary operators
  • Dynamic styling based on state
Full Code
jsx
import { useState } from 'react';

function LightSwitch() {
  const [isOn, setIsOn] = useState(false);

  return (
    <div style={{
      background: isOn ? '#ffd700' : '#333',
      color: isOn ? '#000' : '#fff',
      padding: '20px',
      transition: 'background 0.3s'
    }}>
      <h1>Light is {isOn ? 'ON' : 'OFF'}</h1>
      <p>{isOn ? 'The room is bright!' : 'It\'s dark in here.'}</p>
      <button onClick={() => setIsOn(!isOn)}>Toggle Light</button>
    </div>
  );
}
Step-by-Step Breakdown

useState(false) — The light starts off. A single boolean drives the entire UI: background colour, text colour, heading text, and description text all derive from this one value.

setIsOn(!isOn) — The NOT operator flips the boolean. If isOn is true, !isOn is false, and vice versa. For rapid-fire scenarios (double-clicks), prefer setIsOn(prev => !prev) to avoid stale closure issues.

isOn ? '#ffd700' : '#333' — The ternary operator is your best friend for boolean-driven UI. It reads as: 'if isOn is true, use gold; otherwise, use dark grey.' This pattern works for text, styles, class names, and conditional rendering.

What Would Break
  • Write onClick={setIsOn(!isOn)} without the arrow function → This calls setIsOn immediately during render, not on click, causing an infinite loop.
  • Set initial state to null instead of false → null is falsy so the ternary works by accident. But if you later check isOn === false, null !== false breaks your logic.
  • Forget the transition CSS property → The toggle still works but feels jarring. The 0.3s transition makes state changes feel smooth and intentional.
Practice Tasks

Task 1: Build a dark/light theme toggle. Clicking a button switches between dark background + light text and light background + dark text. Display the current theme name.

Hint: useState(false) where false = light mode. Apply conditional styles to a wrapper div.

Try It Yourself

Task 2: Create a password visibility toggle. An input field shows a password. A button toggles between type='password' and type='text'.

Hint: useState(false) for showPassword. The input's type is showPassword ? 'text' : 'password'.

Try It Yourself

Task 3: Build an accordion FAQ. A question is always visible. Clicking it toggles the answer's visibility.

Hint: useState(false) for isOpen. Render the answer only when isOpen is true: {isOpen && <p>Answer</p>}.

Try It Yourself

Example 3: Signup Form with Validation — Object State

Forms are the backbone of web apps. This teaches you to manage multiple related fields in a single state object, validate in real time, and compute derived values like form validity.

What You'll Learn
  • Storing an object in state
  • Updating one field without losing others (spread operator)
  • Dynamic property names with computed keys [e.target.name]
  • Real-time validation with error state
  • Derived values (isFormValid) computed during render
Full Code
jsx
import { useState } from 'react';

function SignupForm() {
  const [form, setForm] = useState({ name: '', email: '', age: '', password: '' });
  const [errors, setErrors] = useState({});
  const [submitted, setSubmitted] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm({ ...form, [name]: value });

    setErrors((prevErrors) => {
      const newErrors = { ...prevErrors };
      if (name === 'name') {
        if (value.length < 3) newErrors.name = 'Name must be 3+ characters';
        else delete newErrors.name;
      }
      if (name === 'email') {
        if (!value.includes('@')) newErrors.email = 'Invalid email format';
        else delete newErrors.email;
      }
      if (name === 'age') {
        const ageNum = parseInt(value, 10);
        if (isNaN(ageNum) || ageNum < 18) newErrors.age = 'Must be 18 or older';
        else delete newErrors.age;
      }
      if (name === 'password') {
        if (value.length < 8 || !/\d/.test(value)) newErrors.password = '8+ chars with a number';
        else delete newErrors.password;
      }
      return newErrors;
    });
  };

  const clearForm = () => {
    setForm({ name: '', email: '', age: '', password: '' });
    setErrors({});
    setSubmitted(false);
  };

  const isFormValid = Object.keys(errors).length === 0 &&
    Object.values(form).every(value => value);

  return (
    <div>
      <h1>Signup Form</h1>
      {['name', 'email', 'age', 'password'].map(field => (
        <div key={field}>
          <input
            type={field === 'password' ? 'password' : field === 'age' ? 'number' : 'text'}
            name={field}
            value={form[field]}
            onChange={handleChange}
            placeholder={field.charAt(0).toUpperCase() + field.slice(1)}
          />
          {errors[field] && <p style={{ color: 'red' }}>{errors[field]}</p>}
        </div>
      ))}
      <button onClick={clearForm}>Clear</button>
      <button onClick={() => setSubmitted(true)} disabled={!isFormValid}>Submit</button>
      {submitted && isFormValid && <p style={{ color: 'green' }}>Submitted!</p>}
    </div>
  );
}
Step-by-Step Breakdown

useState({ name: '', email: '', age: '', password: '' }) — A single state object holds all form fields. This is better than four separate useState calls because the fields are related — they represent one form.

setForm({ ...form, [name]: value }) — The spread operator copies all existing fields, then [name]: value overwrites just the one that changed. The square brackets are a computed property name — if name is 'email', this becomes { ...form, email: value }. One handler works for all inputs.

setErrors((prevErrors) => { ... }) — Validation uses the functional update form. We clone prevErrors with spread, then add or delete error keys. Using delete removes the key entirely, keeping the errors object clean.

const isFormValid = ... — A derived value, not state. Computed fresh every render from current errors and form values. No need for a separate useState — that would create a sync problem.

What Would Break
  • Use setForm({ [name]: value }) without the spread → Every keystroke wipes all other fields. Only the field you just typed in would have a value.
  • Store errors as an array instead of an object → You'd need to search the array to check if a field has an error. An object gives O(1) lookup by field name.
  • Compute isFormValid inside useState → You'd need to call setIsFormValid every time form or errors change. The derived value approach is simpler and always correct.
Practice Tasks

Task 1: Build a contact form with name, email, and message fields. Add validation (name required, email must contain @, message min 10 chars). Show a character counter for the message.

Hint: The character counter is message.length — a derived value, not separate state.

Try It Yourself

Task 2: Create a profile editor with first name, last name, and bio. Display a live preview card as the user types. Add a Reset button.

Hint: The preview just reads from form state — no separate state needed.

Try It Yourself

Example 4: To-Do List with Filters — Array State

Lists are the most common UI pattern. This covers every array operation you'll need — adding, removing, updating, filtering, and sorting — all with immutable updates.

What You'll Learn
  • Storing arrays in state
  • Adding: [...tasks, newTask]
  • Removing: tasks.filter(t => t.id !== id)
  • Updating: tasks.map(t => t.id === id ? {...t, done: true} : t)
  • Derived data: filtering and sorting without extra state
Full Code
jsx
import { useState } from 'react';

function TodoList() {
  const [tasks, setTasks] = useState([]);
  const [newTask, setNewTask] = useState({ text: '', priority: 'Medium' });
  const [filter, setFilter] = useState('all');
  const [sortBy, setSortBy] = useState('added');

  const addTask = () => {
    if (newTask.text.trim()) {
      setTasks([...tasks, {
        id: Date.now(), text: newTask.text, completed: false,
        priority: newTask.priority, createdAt: new Date()
      }]);
      setNewTask({ text: '', priority: 'Medium' });
    }
  };

  const toggleTask = (id) => {
    setTasks(tasks.map(task =>
      task.id === id ? { ...task, completed: !task.completed } : task
    ));
  };

  const deleteTask = (id) => setTasks(tasks.filter(task => task.id !== id));
  const clearCompleted = () => setTasks(tasks.filter(task => !task.completed));

  // Derived data — computed from state, not stored in state
  const filteredTasks = tasks.filter(task => {
    if (filter === 'all') return true;
    if (filter === 'active') return !task.completed;
    return task.completed;
  });

  const sortedTasks = [...filteredTasks].sort((a, b) => {
    if (sortBy === 'added') return b.createdAt - a.createdAt;
    if (sortBy === 'alphabetical') return a.text.localeCompare(b.text);
    if (sortBy === 'priority') {
      const p = { High: 1, Medium: 2, Low: 3 };
      return p[a.priority] - p[b.priority];
    }
    return 0;
  });

  return (
    <div>
      <h1>To-Do List ({filteredTasks.length}/{tasks.length})</h1>
      <input type="text" value={newTask.text}
        onChange={(e) => setNewTask({ ...newTask, text: e.target.value })}
        placeholder="Add a task" />
      <select value={newTask.priority}
        onChange={(e) => setNewTask({ ...newTask, priority: e.target.value })}>
        <option value="High">High</option>
        <option value="Medium">Medium</option>
        <option value="Low">Low</option>
      </select>
      <button onClick={addTask}>Add</button>
      <div>
        {['all', 'active', 'completed'].map(f => (
          <button key={f} onClick={() => setFilter(f)}
            style={{ fontWeight: filter === f ? 'bold' : 'normal' }}>
            {f.charAt(0).toUpperCase() + f.slice(1)}
          </button>
        ))}
      </div>
      <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
        <option value="added">Newest First</option>
        <option value="alphabetical">A-Z</option>
        <option value="priority">Priority</option>
      </select>
      <button onClick={clearCompleted}>Clear Completed</button>
      <ul>
        {sortedTasks.map(task => (
          <li key={task.id} style={{
            textDecoration: task.completed ? 'line-through' : 'none',
            color: task.priority === 'High' ? 'red' : task.priority === 'Low' ? 'grey' : 'black'
          }}>
            <input type="checkbox" checked={task.completed} onChange={() => toggleTask(task.id)} />
            {task.text} [{task.priority}]
            <button onClick={() => deleteTask(task.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}
Step-by-Step Breakdown

setTasks([...tasks, newItem]) — Adding to an array. Spread copies existing tasks, then the new item is appended. Never use push() — it mutates the existing array and React won't detect the change.

tasks.map(task => task.id === id ? { ...task, completed: !task.completed } : task) — Updating one item. map() creates a new array. The ternary checks each item's ID: if it matches, return an updated copy; otherwise return the original. This is the standard 'update one item in an array' pattern.

tasks.filter(task => task.id !== id) — Removing from an array. filter() creates a new array with only the items that pass the test. The original array is untouched.

filteredTasks and sortedTasks — Derived values computed during render, not stored in state. If we stored them separately, we'd need to manually re-compute them every time tasks, filter, or sortBy changed — a recipe for bugs.

What Would Break
  • Use tasks.push(newItem) instead of [...tasks, newItem] → push() mutates the original array. React sees the same reference and skips the re-render.
  • Sort filteredTasks directly: filteredTasks.sort() → .sort() mutates in place. Use [...filteredTasks].sort() to create a safe copy first.
  • Store filteredTasks in useState → You'd need to call setFilteredTasks every time tasks or filter changes. Forgetting one path means stale data.
Practice Tasks

Task 1: Build a shopping list. Users add items (name + quantity). Show the list with +/- quantity buttons and remove. Display total items.

Hint: Each item is { id, name, quantity }. Total is items.reduce((sum, i) => sum + i.quantity, 0).

Try It Yourself

Task 2: Create an Emoji Reaction Tracker. Display messages, each with emoji reaction buttons (👍 ❤️ 😂). Clicking an emoji increments its count for that message.

Hint: Each message has reactions: { like: 0, love: 0, laugh: 0 }. Update with nested spread: { ...msg, reactions: { ...msg.reactions, [emoji]: count + 1 } }.

Try It Yourself

Task 3: Build a Kanban board with three columns: Todo, In Progress, Done. Users add tasks to Todo and move them forward with a button.

Hint: All tasks in one array with a status field. Filter by status per column. Move = map to update the status.

Try It Yourself

Example 5: E-Commerce Shopping Cart

This is where useState feels like real production code. An e-commerce cart combines objects, arrays, derived values, and validation into a single complex component.

What You'll Learn
  • Managing multiple coordinated state variables
  • The find + map pattern (update existing item or add new one)
  • Derived calculations (totals, discounts, category filters)
  • Input validation before state updates
Full Code
jsx
import { useState } from 'react';

function ShoppingCart() {
  const [cart, setCart] = useState([]);
  const [product, setProduct] = useState({ name: '', price: '', category: '', stock: '' });
  const [discount, setDiscount] = useState(0);
  const [coupon, setCoupon] = useState('');
  const [selectedCategory, setSelectedCategory] = useState('all');

  const handleChange = (e) => {
    setProduct({ ...product, [e.target.name]: e.target.value });
  };

  const addToCart = () => {
    if (product.name.trim() && product.price > 0 && product.stock > 0) {
      const existingItem = cart.find(item => item.name === product.name);

      if (existingItem) {
        if (existingItem.quantity >= parseInt(product.stock)) {
          alert('Out of stock!');
          return;
        }
        setCart(cart.map(item =>
          item.name === product.name ? { ...item, quantity: item.quantity + 1 } : item
        ));
      } else {
        setCart([...cart, {
          id: Date.now(),
          name: product.name,
          price: parseFloat(product.price),
          category: product.category || 'General',
          quantity: 1,
          stock: parseInt(product.stock),
        }]);
      }
      setProduct({ name: '', price: '', category: '', stock: '' });
    }
  };

  const updateQuantity = (id, change) => {
    setCart(cart.map(item =>
      item.id === id
        ? { ...item, quantity: Math.min(Math.max(1, item.quantity + change), item.stock) }
        : item
    ));
  };

  const removeItem = (id) => setCart(cart.filter(item => item.id !== id));

  const applyCoupon = () => {
    if (coupon.toLowerCase() === 'save10') {
      setDiscount(0.1);
      setCoupon('');
    } else {
      alert('Invalid coupon');
      setCoupon('');
    }
  };

  const clearCart = () => {
    setCart([]);
    setDiscount(0);
    setSelectedCategory('all');
  };

  // Derived values — all computed from state, never stored separately
  const totalPrice = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const discountedPrice = totalPrice * (1 - discount);
  const categories = ['all', ...new Set(cart.map(item => item.category))];
  const filteredCart = cart.filter(item =>
    selectedCategory === 'all' || item.category === selectedCategory
  );

  return (
    <div>
      <h1>Shopping Cart</h1>
      <div>
        <input type="text" name="name" value={product.name} onChange={handleChange} placeholder="Product" />
        <input type="number" name="price" value={product.price} onChange={handleChange} placeholder="Price" />
        <input type="text" name="category" value={product.category} onChange={handleChange} placeholder="Category" />
        <input type="number" name="stock" value={product.stock} onChange={handleChange} placeholder="Stock" />
        <button onClick={addToCart}>Add to Cart</button>
      </div>
      <div>
        <input value={coupon} onChange={(e) => setCoupon(e.target.value)} placeholder="Coupon code" />
        <button onClick={applyCoupon}>Apply</button>
        {discount > 0 && <span> {discount * 100}% off!</span>}
      </div>
      <div>
        {categories.map(cat => (
          <button key={cat} onClick={() => setSelectedCategory(cat)}
            style={{ fontWeight: selectedCategory === cat ? 'bold' : 'normal' }}>
            {cat}
          </button>
        ))}
      </div>
      <ul>
        {filteredCart.map(item => (
          <li key={item.id}>
            {item.name} - £{item.price.toFixed(2)} x {item.quantity} = £{(item.price * item.quantity).toFixed(2)}
            <button onClick={() => updateQuantity(item.id, -1)}>-</button>
            <button onClick={() => updateQuantity(item.id, 1)}>+</button>
            <button onClick={() => removeItem(item.id)}>Remove</button>
          </li>
        ))}
      </ul>
      <h2>Total: £{totalPrice.toFixed(2)}</h2>
      {discount > 0 && <h2>After Discount: £{discountedPrice.toFixed(2)}</h2>}
      <button onClick={clearCart}>Clear Cart</button>
    </div>
  );
}
Step-by-Step Breakdown

The find + map pattern (addToCart) — First, cart.find() checks if the product already exists. If it does, we use map() to increment only that item's quantity. If not, we spread the cart and append a new item. This avoids duplicates while properly incrementing existing items.

Math.min(Math.max(1, item.quantity + change), item.stock) — This clamps the quantity between 1 and the stock limit. Math.max(1, ...) prevents going below 1, and Math.min(..., item.stock) prevents exceeding stock. A common pattern for bounded number inputs.

new Set(cart.map(item => item.category)) — Creates a unique list of categories from the cart items. We spread it into an array and prepend 'all' to create dynamic filter buttons. This is derived data — no separate state needed.

What Would Break
  • Skip the find check and always push a new item → Users would see 'Apple x1' listed three times instead of 'Apple x3'. The find + map pattern is how real carts work.
  • Store totalPrice in useState → Every cart change would need a manual setTotalPrice call. Missing one means the price display is wrong. Derive it instead.
  • Use cart.push() in addToCart → Mutates the array. React sees the same reference and the new item never appears on screen.
Practice Tasks

Task 1: Build a Meal Planner with a calorie budget. Users add meals (name + calories). Display the total calories and remaining budget. Highlight when over budget.

Hint: useState(2000) for budget, useState([]) for meals. Remaining = budget - total. Style red when remaining < 0.

Try It Yourself

Task 2: Create a Split Bill Calculator. Users add people and items with prices. Each item can be assigned to one or more people. Calculate what each person owes.

Hint: Items have an assignedTo array of person names. Each person's total = sum of (item.price / item.assignedTo.length) for items they're assigned to.

Try It Yourself

Example 6: Task Management Dashboard — Nested State

Real applications often have hierarchical data: projects contain tasks, orders contain items, threads contain messages. This example teaches you to manage nested state immutably — the trickiest useState pattern.

What You'll Learn
  • Nested state: arrays of objects containing arrays
  • Deep immutable updates with nested map + spread
  • Multiple form states coordinating with list state
  • When useState starts feeling complex (hint: useReducer exists)
Full Code
jsx
import { useState } from 'react';

function TaskDashboard() {
  const [projects, setProjects] = useState([]);
  const [newProject, setNewProject] = useState({ name: '', description: '' });
  const [newTask, setNewTask] = useState({
    projectId: null, title: '', status: 'todo', priority: 'Medium', dueDate: ''
  });

  const addProject = () => {
    if (newProject.name.trim()) {
      setProjects([...projects, {
        id: Date.now(), name: newProject.name,
        description: newProject.description, tasks: []
      }]);
      setNewProject({ name: '', description: '' });
    }
  };

  const addTask = () => {
    if (newTask.title.trim() && newTask.projectId) {
      setProjects(projects.map(project =>
        project.id === newTask.projectId
          ? { ...project, tasks: [...project.tasks, {
              id: Date.now(), title: newTask.title, status: newTask.status,
              priority: newTask.priority, dueDate: newTask.dueDate
            }]}
          : project
      ));
      setNewTask({ projectId: null, title: '', status: 'todo', priority: 'Medium', dueDate: '' });
    }
  };

  const updateTaskStatus = (projectId, taskId, newStatus) => {
    setProjects(projects.map(project =>
      project.id === projectId
        ? { ...project, tasks: project.tasks.map(task =>
            task.id === taskId ? { ...task, status: newStatus } : task
          )}
        : project
    ));
  };

  const deleteProject = (projectId) => {
    setProjects(projects.filter(p => p.id !== projectId));
  };

  const deleteTask = (projectId, taskId) => {
    setProjects(projects.map(project =>
      project.id === projectId
        ? { ...project, tasks: project.tasks.filter(task => task.id !== taskId) }
        : project
    ));
  };

  return (
    <div>
      <h1>Task Dashboard</h1>
      <div>
        <h2>Add Project</h2>
        <input value={newProject.name}
          onChange={(e) => setNewProject({ ...newProject, name: e.target.value })}
          placeholder="Project name" />
        <input value={newProject.description}
          onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
          placeholder="Description" />
        <button onClick={addProject}>Add Project</button>
      </div>
      <div>
        <h2>Add Task</h2>
        <select value={newTask.projectId || ''}
          onChange={(e) => setNewTask({ ...newTask, projectId: parseInt(e.target.value) || null })}>
          <option value="">Select Project</option>
          {projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
        </select>
        <input value={newTask.title}
          onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
          placeholder="Task title" />
        <select value={newTask.status}
          onChange={(e) => setNewTask({ ...newTask, status: e.target.value })}>
          <option value="todo">To Do</option>
          <option value="in-progress">In Progress</option>
          <option value="done">Done</option>
        </select>
        <select value={newTask.priority}
          onChange={(e) => setNewTask({ ...newTask, priority: e.target.value })}>
          <option value="High">High</option>
          <option value="Medium">Medium</option>
          <option value="Low">Low</option>
        </select>
        <input type="date" value={newTask.dueDate}
          onChange={(e) => setNewTask({ ...newTask, dueDate: e.target.value })} />
        <button onClick={addTask}>Add Task</button>
      </div>
      <h2>Projects ({projects.length})</h2>
      {projects.map(project => (
        <div key={project.id} style={{ border: '1px solid #ccc', padding: '10px', margin: '10px 0' }}>
          <h3>{project.name} — {project.description || 'No description'}</h3>
          <button onClick={() => deleteProject(project.id)}>Delete Project</button>
          <ul>
            {project.tasks.map(task => (
              <li key={task.id} style={{
                color: task.dueDate && new Date(task.dueDate) < new Date() ? 'red' : 'black'
              }}>
                {task.title} | {task.status} | {task.priority} | Due: {task.dueDate || 'None'}
                <select value={task.status}
                  onChange={(e) => updateTaskStatus(project.id, task.id, e.target.value)}>
                  <option value="todo">To Do</option>
                  <option value="in-progress">In Progress</option>
                  <option value="done">Done</option>
                </select>
                <button onClick={() => deleteTask(project.id, task.id)}>Delete</button>
              </li>
            ))}
          </ul>
        </div>
      ))}
    </div>
  );
}
Step-by-Step Breakdown

The nested update pattern (addTask) — To add a task to a specific project, we map over all projects. For the matching project, we spread it and replace its tasks array with [...project.tasks, newTask]. For all other projects, we return them unchanged. This is two levels of immutable updates: outer map for projects, inner spread for the tasks array.

updateTaskStatus — Three levels deep: map projects → find matching project → map its tasks → find matching task → update its status. Each level creates a new reference. This pattern works but gets verbose — it's a signal that useReducer might be a better fit for this level of complexity.

deleteTask — map projects (to find the right one) → filter its tasks (to remove the target). Notice we use map for the outer level (we're updating one project, not removing it) and filter for the inner level (we're removing a task).

What Would Break
  • Directly push into project.tasks → You're mutating the project object inside the projects array. React sees the same projects reference and skips the re-render. The task is added in memory but invisible.
  • Use find instead of map to update a project → find returns a reference to the original object. Modifying it mutates your state. map creates a new array with a new object for the changed project.
  • Forget parseInt on projectId from the select → e.target.value is always a string. Comparing string '123' to number 123 with === fails, so the task never gets added to any project.
Practice Tasks

Task 1: Build a class roster app. Users create classes (name). Each class can have students added to it. Students can be removed individually. Display the student count per class.

Hint: Same nested pattern — classes contain students. Map to find the class, spread to add/filter students.

Try It Yourself

Example 7: Quiz App — Multi-Step Coordinated State

Interactive UIs like quizzes, wizards, and onboarding flows require multiple state variables working together — tracking the current step, recording answers, computing scores, and handling transitions. This is the capstone example.

What You'll Learn
  • Coordinating 5+ state variables that depend on each other
  • Index-based navigation through an array of data
  • Delayed state transitions with setTimeout
  • Resetting all state to initial values
  • Conditional rendering for multi-screen UIs
Full Code
jsx
import { useState } from 'react';

function QuizApp() {
  const questions = [
    { id: 1, text: 'What is useState?', options: ['Hook', 'Component', 'Library'],
      answer: 'Hook', explanation: 'useState is a Hook for managing state in functional components.' },
    { id: 2, text: 'What does setState do?', options: ['Updates DOM', 'Updates state', 'Fetches data'],
      answer: 'Updates state', explanation: 'setState updates state and triggers a re-render.' },
    { id: 3, text: "What's the Virtual DOM?", options: ['Real DOM', 'Memory copy', 'Database'],
      answer: 'Memory copy', explanation: 'The Virtual DOM is a lightweight copy for efficient updates.' },
  ];

  const [currentQuestion, setCurrentQuestion] = useState(0);
  const [answers, setAnswers] = useState([]);
  const [score, setScore] = useState(0);
  const [showResults, setShowResults] = useState(false);
  const [feedback, setFeedback] = useState('');

  const handleAnswer = (option) => {
    const isCorrect = option === questions[currentQuestion].answer;
    setAnswers([...answers, {
      questionId: questions[currentQuestion].id, option, isCorrect
    }]);
    setFeedback(isCorrect
      ? 'Correct! 🎉'
      : `Wrong! ${questions[currentQuestion].explanation}`
    );
    if (isCorrect) setScore(score + 1);

    setTimeout(() => {
      setFeedback('');
      if (currentQuestion + 1 < questions.length) {
        setCurrentQuestion(currentQuestion + 1);
      } else {
        setShowResults(true);
      }
    }, 2000);
  };

  const resetQuiz = () => {
    setCurrentQuestion(0);
    setAnswers([]);
    setScore(0);
    setShowResults(false);
    setFeedback('');
  };

  return (
    <div>
      <h1>React Quiz</h1>
      <p>Progress: {currentQuestion + 1}/{questions.length} | Score: {score}</p>
      {!showResults ? (
        <div>
          <h2>{questions[currentQuestion].text}</h2>
          {questions[currentQuestion].options.map(option => (
            <button key={option} onClick={() => handleAnswer(option)}
              disabled={!!feedback}>
              {option}
            </button>
          ))}
          {feedback && <p style={{
            color: feedback.includes('Correct') ? 'green' : 'red'
          }}>{feedback}</p>}
        </div>
      ) : (
        <div>
          <h2>Quiz Complete! Score: {score}/{questions.length}</h2>
          <ul>
            {answers.map((a, i) => (
              <li key={i} style={{ color: a.isCorrect ? 'green' : 'red' }}>
                Q{a.questionId}: {a.option} ({a.isCorrect ? '✓' : '✗'})
              </li>
            ))}
          </ul>
          <button onClick={resetQuiz}>Restart</button>
        </div>
      )}
    </div>
  );
}
Step-by-Step Breakdown

Five coordinated states — currentQuestion (index), answers (array of past responses), score (running total), showResults (boolean to swap screens), and feedback (temporary message). Each serves a distinct purpose, and the handleAnswer function updates four of them in a single user action. React batches all these updates into one re-render.

questions[currentQuestion] — Using an index to navigate through an array is a powerful pattern. The questions data is static (defined outside state since it never changes), and currentQuestion acts as a pointer. Incrementing the index advances to the next question; reaching the end triggers the results screen.

setTimeout for delayed transitions — After showing feedback for 2 seconds, we advance to the next question. The disabled={!!feedback} on buttons prevents double-clicks during the delay. This pattern of 'show feedback → wait → transition' is common in interactive UIs.

resetQuiz — Resets all five states to their initial values. This is where having many useState calls gets tedious — you need to remember to reset each one. For complex multi-state components like this, useReducer with a RESET action might be cleaner.

What Would Break
  • Store questions in useState → The questions never change, so putting them in state adds unnecessary complexity. Static data should be a regular variable or constant.
  • Use setScore(score + 1) inside setTimeout → By the time setTimeout fires, score might be stale if other updates happened. Use setScore(prev => prev + 1) inside any async or delayed callback.
  • Forget to disable buttons during the feedback delay → Users can click multiple answers for the same question, recording duplicate answers and inflating the score.
  • Skip the resetQuiz function and reload the page instead → This destroys the entire component tree and loses any parent state. Always provide an in-app reset.
Practice Tasks

Task 1: Build a timed quiz. Each question has a 10-second countdown. If time runs out, it counts as wrong and auto-advances. Display the timer.

Hint: Use useState for timeLeft and setInterval in a useEffect (or setTimeout in a recursive pattern). Clear the timer when the user answers or time hits 0.

Try It Yourself

Task 2: Create a quiz with a hint system. Each question has a hint that costs 1 point to reveal. Track total hints used. Display hints when requested.

Hint: Add a hintsUsed state. Each question's hint is shown via a boolean (showHint). Revealing deducts from score.

Try It Yourself

What's Next

You've now mastered useState across every common pattern: numbers, booleans, strings, objects, arrays, nested state, and multi-step coordination. Here's where to go next:

  • useEffect — Synchronise your components with external systems like APIs, timers, and the DOM. This is the natural next step after useState.
  • useState + useEffect combined — Learn how state changes trigger side effects, and how effects can update state back, creating the reactive loop that powers dynamic apps.
  • useReducer — When your component has 5+ state variables that update together (like our Quiz example), useReducer consolidates them into a single dispatch pattern.