Immutability & Array Manipulation in React

Introduction

Icebreaker: Have you ever edited a Google Doc and had your changes mysteriously not save? Or clicked "like" on social media and watched the heart icon stay gray? That's usually a state management bug—the app's data changed, but the UI didn't know to update. In React, these bugs almost always trace back to one mistake: mutating state directly.

Real-world scenario: Every interactive web app you use—X's timeline, Notion's task lists, Spotify's playlists—manages collections of data that users can add to, remove from, mark complete, or filter. Under the hood, these apps use the exact patterns you'll learn in this lesson. When you favorite a song, Spotify doesn't mutate the original playlist array—it creates a new one. When you archive an email, Gmail doesn't delete it from the master array—it filters the view. Understanding immutability is understanding how modern web apps actually work.

In the previous lesson, you built a todo app with useLocalStorage that persisted across refreshes. That was a big win. But this lesson strips back to basics to focus purely on immutability patterns—how to update arrays and objects the React way. We'll start with plain useState (no persistence), learn the core techniques through isolated exercises, and then in the final assignment you'll combine everything into a proper useTodos hook with persistence.

Think of this as learning the fundamentals in a clean environment before adding complexity back in.

Here's the problem: when you try to implement these features, you might be tempted to do this:

const myTodos = [{ id: 1, text: "Buy milk", completed: false }];
myTodos[0].completed = true; // Just change it, right?

It feels natural—you're just updating the list. But React hates this approach. When you mutate state directly, React doesn't know that something changed. It looks at your todos array and thinks "same array reference, nothing to re-render," and your UI stays stale while your data is secretly different.

This lesson teaches you the React patterns for updating and filtering arrays immutably—the way React expects.

Learning objectives:

  • Understand why immutability matters in React
  • Use the spread operator to create new objects and arrays
  • Modify single items in an array without breaking React reactivity
  • Filter arrays to derive new state
  • Extend your existing todo app with toggle, delete, and filter features

Setup: Use the template repo provided in BrightSpace for this lesson. We're starting fresh (not continuing from the previous lesson's app) to focus purely on immutability patterns without localStorage complexity. You'll add persistence back in the final assignment.


For the exercises below, start with this simple todo app as your baseline:

import { useState } from "react";

export default function App() {
  const [todos, setTodos] = useState([]);

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-900 p-4 text-white">
      <h1 className="text-2xl font-black tracking-wider uppercase">
        Todo List
      </h1>

      <input
        className="rounded border p-2"
        type="text"
        id="todo-input"
        aria-label="Add Todo"
        onBlur={(e) => {
          setTodos([...todos, { id: Date.now(), text: e.target.value }]);
          e.target.value = "";
        }}
      />

      <ul className="text-center">
        {todos.map(({ id, text }) => (
          <li
            key={id}
            className="border-b border-slate-600 py-2 last:border-b-0"
          >
            {text}
          </li>
        ))}
      </ul>
    </main>
  );
}

This starter app uses plain useState with no persistence. That's intentional—we're focusing purely on immutability patterns in this lesson, and we'll ignore the encapsulated hook rule for now too. You'll add localStorage persistence back in the assessment when you create the useTodos hook.

Core Concept Overview

The Immutability Principle

Think of React state like a photograph. When you update a photo, you don't edit the original—you take a new photo. React compares the old photo to the new photo and says "these are different, I need to re-render."

If you mutate state directly, you're editing the original photo in place. React compares it to itself and sees... the same thing. No change detected. No re-render.

The rule: Always create a new version of your state, never modify the original.

The Spread Operator: Creating New Objects

Let's say you have a todo object:

const todo = { id: 1, text: "Buy milk", completed: false };

You want to toggle completed. The wrong way:

// ❌ DON'T DO THIS - mutates the original
todo.completed = true;

The right way—use the spread operator ...:

// ✅ CREATE A NEW OBJECT with the change
const updatedTodo = { ...todo, completed: !todo.completed };
// Result: { id: 1, text: "Buy milk", completed: true }

What's happening: { ...todo } copies all the properties from todo into a brand new object. Then completed: !todo.completed overwrites just that property. Everything else stays the same, but it's a new object. React sees the difference and re-renders.

Checkpoint question: If you have const person = { name: "Alex", age: 25 }, write code that creates a new person object with the same name but age 26.


Mapping: Modifying Arrays

Now imagine you have an array of todos and you need to update just one of them:

const todos = [
  { id: 1, text: "Buy milk", completed: false },
  { id: 2, text: "Study React", completed: false },
  { id: 3, text: "Exercise", completed: false },
];

User clicks on todo #2. You need to toggle its completed property. But you can't just do:

// ❌ DON'T DO THIS - mutates the original array
todos[1].completed = true;

Instead, use map() to create a new array. map() loops through each item and lets you decide what to return:

// ✅ CREATE A NEW ARRAY with one item changed
const updatedTodos = todos.map((todo) => {
  if (todo.id === 2) {
    // This is the todo we're updating - return a new version
    return { ...todo, completed: !todo.completed };
  }
  // All other todos stay exactly the same
  return todo;
});

Or more concisely:

const updatedTodos = todos.map((todo) =>
  todo.id === 2 ? { ...todo, completed: !todo.completed } : todo,
);

Translation: "For each todo, if it's the one I'm looking for, return a new version with completed toggled. Otherwise, return it unchanged. Give me the new array."

React sees this new array and re-renders. The old array is untouched.

Checkpoint question: Write code using map() that takes the todos array above and returns a new array where todo #1 has its text changed to "Buy milk (2% fat)".


Filtering: Deriving State

Filter creates a new array with only the items that pass a test:

// Show only completed todos
const completedTodos = todos.filter((todo) => todo.completed === true);

// Show only active (not completed) todos
const activeTodos = todos.filter((todo) => todo.completed === false);

Note that you could use JS's truthiness here:

// Show only completed todos
const completedTodos = todos.filter((todo) => todo.completed);

// Show only active (not completed) todos
const activeTodos = todos.filter((todo) => !todo.completed);

filter() returns a new array. It doesn't touch the original. This is perfect for view filtering—you want to display different subsets without changing your actual data.

Checkpoint question: Write code that filters the todos array to show only items with text containing the word "React".


Hands-On Application

Paste these exercises into your app.jsx one at a time. Test each one, make sure you understand what's happening, then commit with a clear message before moving to the next. Take screenshots of your working code for each exercise—you'll submit these to prove you did the hands-on work.

Exercise 1: Toggle a Todo

Copy this entire code block into your App component in app.jsx:

import { useState } from "react";

export default function App() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Buy milk", completed: false },
    { id: 2, text: "Study React", completed: false },
  ]);

  const toggleTodo = (todoId) => {
    setTodos(
      todos.map((todo) =>
        todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
      ),
    );
  };

  return (
    <div>
      <button onClick={() => toggleTodo(1)}>Toggle Todo 1</button>
      <pre>{JSON.stringify(todos, null, 2)}</pre>
    </div>
  );
}

What to test: Click the button and watch the todos array in the JSON display. Todo 1's completed should flip from false to true, then back to false each click.


Exercise 2: Delete a Todo

Replace your Exercise 1 code with this:

import { useState } from "react";

export default function App() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Buy milk", completed: false },
    { id: 2, text: "Study React", completed: false },
    { id: 3, text: "Exercise", completed: false },
  ]);

  const deleteTodo = (todoId) => {
    const updatedTodos = todos.filter((todo) => todo.id !== todoId);
    setTodos(updatedTodos);
  };

  return (
    <div>
      <button onClick={() => deleteTodo(1)}>Delete Todo 1</button>
      <button onClick={() => deleteTodo(2)}>Delete Todo 2</button>
      <button onClick={() => deleteTodo(3)}>Delete Todo 3</button>
      <pre>{JSON.stringify(todos, null, 2)}</pre>
    </div>
  );
}

What to test: Click a delete button. That todo should vanish from the array. Once it's gone, clicking its button does nothing (because there's nothing to delete). The other todos stay in place.


Exercise 3: Filter Display

Replace your code with this:

import { useState } from "react";

export default function App() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Buy milk", completed: false },
    { id: 2, text: "Study React", completed: true },
    { id: 3, text: "Exercise", completed: false },
  ]);

  const [filter, setFilter] = useState("all");

  const displayedTodos = todos.filter((todo) => {
    if (filter === "active") return !todo.completed;
    if (filter === "completed") return todo.completed;

    return true; // "all"
  });

  return (
    <div>
      <div>
        <button onClick={() => setFilter("all")}>All</button>
        <button onClick={() => setFilter("active")}>Active</button>
        <button onClick={() => setFilter("completed")}>Completed</button>
      </div>

      <ul>
        {displayedTodos.map((todo) => (
          <li key={todo.id}>
            {todo.text} - {todo.completed ? "Done" : "Not done"}
          </li>
        ))}
      </ul>
    </div>
  );
}

What to test: Click each filter button. Notice how the list changes but the original todos array doesn't. "All" shows 3 items, "Active" shows 2 (items 1 and 3), "Completed" shows 1 (item 2). The filtering doesn't delete anything—it just changes what's displayed.


Advanced Concepts & Comparisons

Why Not Just Mutate?

You'll see YouTube tutorials where people do this:

// ❌ Mutating state directly
todos[0].completed = true;
setTodos(todos);

React might re-render anyway (depending on the situation), but you're relying on luck, not design. Sometimes it won't re-render when you expect it to. This creates bugs that are brutal to debug because the data is correct but the UI doesn't update.

The immutability pattern is predictable: new state object → guaranteed re-render.

Map vs. For Loop

Older code sometimes uses for loops:

// ❌ Imperative approach (older style)
const updatedTodos = [];
for (let i = 0; i < todos.length; i++) {
  if (todos[i].id === targetId)
    updatedTodos.push({ ...todos[i], completed: !todos[i].completed });
  else updatedTodos.push(todos[i]);
}

vs. the declarative approach:

// ✅ Declarative approach (modern React style)
const updatedTodos = todos.map((todo) =>
  todo.id === targetId ? { ...todo, completed: !todo.completed } : todo,
);

The map() version is clearer: "transform each item in this specific way." It's what you're trying to communicate, and it's what you're actually doing. No noise about loop indices or push operations.


Why Encapsulate in a Custom Hook?

Before we look at the reference pattern, let's talk about why we're bothering with a custom hook at all. You might wonder: "The exercises worked fine with useState directly. Why add complexity?"

Here's why custom hooks matter:

  1. Reusability: If you need todos in multiple components, you can import useTodos anywhere. No copy-pasting logic.

  2. Testability: You can test useTodos independently from any UI. Write tests for addTodo, toggleTodo, etc. without rendering components.

  3. Maintainability: All todo logic lives in one place. If you need to change how deletion works, you change it once in the hook, not in every component.

  4. Separation of concerns: Components focus on "what to render," hooks focus on "how state works." This makes both easier to understand.

  5. Linting compliance: Your project's ESLint rules enforce this pattern for good reason—it leads to better code as projects grow.

The pattern: As a rule of thumb, if you have state plus operations on that state (add, remove, update, filter), wrap it in a custom hook. The component should just call those operations—it shouldn't know the implementation details.


Reference Implementation: Pattern Overview

Here's the architectural pattern for how all these concepts work together. We're showing you the structure and key decisions, but you'll implement the actual code yourself for the assessment.

The useTodos Hook Architecture

Your custom hook should follow this structure:

  1. Persistent state: Use useLocalStorage("todos", []) for the main todos array. You can use the version from useHooks.
  2. UI state: Initialize with useState("all") for filter (all/active/completed)
  3. Operations: Provide functions that use immutable patterns:
    • Adding: spread operator to append
    • Toggling: map to update one item
    • Deleting: filter to remove
  4. Derived data: Calculate displayedTodos by filtering based on current filter
  5. Clean interface: Return only what components need (no raw setters)

The Component Responsibility

Your component should handle:

  • Input field state (what user is currently typing)
  • Event handlers (onClick, onSubmit, etc.)
  • JSX rendering (structure, classes, accessibility)
  • Calling hook functions (but NOT implementing todo logic)

What the component should NOT do:

  • Know how todos are stored (localStorage? array? object?)
  • Implement map/filter logic directly
  • Access setTodos directly

Troubleshooting & Best Practices

"My update isn't showing on screen"

Check: Did you call setTodos() with a new array, or did you mutate the old one?

// ❌ Mutated the old array - React won't re-render
const updated = todos;
updated[0].completed = true;
setTodos(updated);

// ✅ Created a new array - React will re-render
const updated = todos.map((todo) =>
  todo.id === 1 ? { ...todo, completed: true } : todo,
);
setTodos(updated);

"I'm confused about when to use map() vs. filter()"

  • Use map() when you want to transform each item (including keeping it the same)
  • Use filter() when you want to select which items to keep

"The spread operator is confusing me"

Think of it as "copy everything, then override what I specify":

const original = { a: 1, b: 2, c: 3 };
const updated = { ...original, b: 99 };
// Result: { a: 1, b: 99, c: 3 }

It's a shallow copy. If you have nested objects, you need to think harder (but your todos don't have nesting, so you're fine).

Performance Note

Calling map() or filter() on every render creates new arrays. For 100 todos, this is instant. For 10,000 todos, you might optimize later. But premature optimization is a trap—build it correctly first, optimize only if it's slow.


Wrap-Up & Assessment

You've learned the core patterns React requires: immutability, map for updates, filter for derivation.

These aren't optional stylistic choices. They're how React knows when to update your UI.

Key takeaways:

  • Never mutate state directly; create new objects/arrays
  • Spread operator (...) copies properties into a new object
  • map() transforms arrays while maintaining order and identity
  • filter() creates a new array with selected items
  • All three patterns together let you build reactive, predictable UIs

Your Assignment: Build & Teach useTodos

You will build a complete todo application using the patterns from this lesson AND prove you understand them.

Part 1: Build the Features (with a useTodos Hook)

Create src/use-todos.js and implement:

  1. Persistent todos using useLocalStorage("todos", []). Use the useHooks version.
  2. Filter state using useState("all")
  3. Operations:
  • addTodo(text) → ignore empty/whitespace, create new object, append immutably
  • toggleTodo(id) → use map() to flip one item's completed
  • deleteTodo(id) → use filter() to remove item
  1. Derived array: displayedTodos based on filter (all, active, completed)
  2. Return everything the component needs (do NOT export raw setTodos)

Build a complete UI with Tailwind (make it your own style):

  • Input field for adding new todos
  • Add button (or handle on Enter/blur)
  • List of todo items, each with:
    • Visual indication of completed state (strikethrough, different color, checkmark, etc.)
    • Toggle button/clickable area to mark complete/incomplete
    • Delete button
  • Filter buttons: All, Active, Completed
  • Make it look decent—doesn't need to be fancy, but shouldn't be unstyled buttons and plain text

The component should only handle UI concerns (input field state, event handlers, rendering) and call the hook's functions. No business logic leaks into the component.

Part 2: Teach-back Video (Required)

Record an 8-minute max video (screen + voice; face optional but encouraged) covering:

  1. Demo (2 min): Add, toggle, delete, switch filters, refresh to show persistence, show localStorage in DevTools.
  2. Code Walkthrough (5 min): Open use-todos.js and explain each function. For each one, answer: What does it do? Why this array method? What would break if I mutated instead?
  3. Deep Teach (1 min): Pick ONE concept (spread operator, map, filter, or derived state) and teach it like you're explaining to a classmate who has never seen it.

Speak naturally. Don't script it word-for-word. If you sound like you're reading an AI script, you won't get full credit.

Part 3: Written Reflection

Create a GitHub Gist (Markdown format) and answer concisely but thoughtfully:

  1. Paste your hook with inline comments you wrote (not auto-generated). Explain why each part exists.
  2. Explain in your own words why immutability is critical in React.
  3. What part of this lesson did you find most challenging? How did you overcome it?
  4. What was your “aha” moment in this lesson? (Be specific.) Or, did you already know it all? :)
  5. Why is the hook version better (or not)?

Submission Checklist

  • [ ] use-todos.js created with hook encapsulation
  • [ ] All features work: add, toggle, delete, filter, persist
  • [ ] Complete UI built with Tailwind (your own style choices)
  • [ ] Component uses hook (no direct mutation, no raw setTodos elsewhere)
  • [ ] Video uploaded (link provided in submission)
  • [ ] LocalStorage demo shown in video
  • [ ] Reflection GitHub Gist created (link provided in submission)
  • [ ] No lint errors (encapsulation rule satisfied)
  • [ ] No console errors
  • [ ] Clean, descriptive commits (e.g., feat: add toggleTodo, chore: add use-todos hook)

Looking Ahead

You've now built a complete, interactive todo app with persistent state. You understand immutability, which is fundamental to React's mental model. But there's still a pattern we haven't addressed: what happens when multiple components need to share the same state?

Right now, your todo app is self-contained in one component. But real apps have multiple components that need to coordinate. How do you handle state that lives "above" a single component? That's where lifting state up and prop drilling come in—topics we'll explore next as we build more complex component hierarchies.

You'll also eventually need to fetch data from APIs. When you do, you'll see why the patterns you learned here (immutability, deriving state) become even more critical. Mutating server data creates bugs that are almost impossible to debug.


Resources