ReactUpdating Arrays in State

Updating Arrays in State

Arrays are mutable in JavaScript, but when they live in React state you must treat them as immutable. Just like objects, React detects changes via reference equality — mutating an array in place produces the same reference, so React sees no change and skips the re-render.

The solution is always the same: produce a new array using non-mutating operations, then pass that new array to the setter.

Mutating vs Non-Mutating Methods

Operation

Mutating (avoid)

Non-mutating (use this)

Add to end

push(item)

[...arr, item]

Add to start

unshift(item)

[item, ...arr]

Remove by index

splice(i, 1)

arr.filter((_, idx) => idx !== i)

Remove by value

splice(indexOf, 1)

arr.filter(item => item !== val)

Update an item

arr[i] = newVal

arr.map((item, i) => i === idx ? newVal : item)

Sort

arr.sort()

[...arr].sort()

Reverse

arr.reverse()

[...arr].reverse()

Insert at index

splice(i, 0, item)

[...arr.slice(0, i), item, ...arr.slice(i)]

Warning
Methods like `push`, `pop`, `splice`, `sort`, and `reverse` mutate the original array. Never call these directly on a state array. Create a copy first or use a non-mutating alternative.
Adding Items to an Array

JSX
import { useState } from 'react'

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React' },
    { id: 2, text: 'Build a project' },
  ])
  const [input, setInput] = useState('')

  function addTodo() {
    if (!input.trim()) return

    // ✓ Spread creates a new array with the new item at the end
    setTodos(prev => [
      ...prev,
      { id: Date.now(), text: input.trim() },
    ])
    setInput('')
  }

  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.map((todo) => <li key={todo.id}>{todo.text}</li>)}
      </ul>
    </div>
  )
}
Removing Items from an Array

JSX
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React' },
    { id: 2, text: 'Build a project' },
    { id: 3, text: 'Deploy to production' },
  ])

  function removeTodo(id) {
    // filter returns a new array excluding the item with the given id
    setTodos(prev => prev.filter((todo) => todo.id !== id))
  }

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          {todo.text}
          <button onClick={() => removeTodo(todo.id)}>✕</button>
        </li>
      ))}
    </ul>
  )
}
Updating an Item in an Array

Use map to produce a new array where one (or more) items are replaced. Items that should not change are returned as-is; the target item is replaced with a new object:

JSX
function TaskList() {
  const [tasks, setTasks] = useState([
    { id: 1, title: 'Write tests', done: false },
    { id: 2, title: 'Fix bug #42', done: false },
    { id: 3, title: 'Update docs', done: true },
  ])

  function toggleTask(id) {
    setTasks(prev =>
      prev.map((task) =>
        task.id === id
          ? { ...task, done: !task.done }  // new object for the changed item
          : task                           // same reference for unchanged items
      )
    )
  }

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          <input
            type="checkbox"
            checked={task.done}
            onChange={() => toggleTask(task.id)}
          />
          <span style={{ textDecoration: task.done ? 'line-through' : 'none' }}>
            {task.title}
          </span>
        </li>
      ))}
    </ul>
  )
}
Note
The key pattern is `map(item => item.id === id ? { ...item, changed: newVal } : item)`. Only the target item gets a new object reference; all other items keep their existing references, which helps `React.memo` avoid unnecessary child re-renders.
Inserting an Item at a Specific Index

JSX
function insertAt(arr, index, newItem) {
  return [
    ...arr.slice(0, index),  // everything before the insertion point
    newItem,                  // the new item
    ...arr.slice(index),     // everything from the insertion point onwards
  ]
}

// Usage:
setItems(prev => insertAt(prev, 2, { id: Date.now(), text: 'New middle item' }))
Sorting Arrays in State

Array.sort() mutates in place. Create a copy first with the spread operator, then sort the copy:

JSX
function SortableList() {
  const [items, setItems] = useState([
    { id: 1, name: 'Banana' },
    { id: 2, name: 'Apple' },
    { id: 3, name: 'Cherry' },
  ])

  function sortAZ() {
    // ✗ WRONG — sorts the state array in place; no new reference
    // setItems(items.sort((a, b) => a.name.localeCompare(b.name)))

    // ✓ CORRECT — spread creates a new array, then sort
    setItems(prev => [...prev].sort((a, b) => a.name.localeCompare(b.name)))
  }

  function sortZA() {
    setItems(prev => [...prev].sort((a, b) => b.name.localeCompare(a.name)))
  }

  return (
    <div>
      <button onClick={sortAZ}>A → Z</button>
      <button onClick={sortZA}>Z → A</button>
      <ul>
        {items.map((item) => <li key={item.id}>{item.name}</li>)}
      </ul>
    </div>
  )
}
Complex Array State with Immer

When array operations get complex — deeply nested items, reordering with multiple steps, conditional updates across many items — Immer removes the boilerplate:

JSX
import { useImmer } from 'use-immer'

function KanbanBoard() {
  const [cards, updateCards] = useImmer([
    { id: 1, title: 'Design', column: 'todo', priority: 'high' },
    { id: 2, title: 'Implement', column: 'todo', priority: 'medium' },
    { id: 3, title: 'Review', column: 'done', priority: 'low' },
  ])

  function moveToInProgress(id) {
    updateCards(draft => {
      const card = draft.find((c) => c.id === id)
      if (card) card.column = 'in-progress'   // direct mutation on the draft ✓
    })
  }

  function setPriority(id, priority) {
    updateCards(draft => {
      const card = draft.find((c) => c.id === id)
      if (card) card.priority = priority
    })
  }

  return (/* ... */)
}
Tip
Immer's `useImmer` hook is a drop-in replacement for `useState` that accepts an updater function with a mutable draft. It is ideal once your array items are objects and you need to update their properties.
Full CRUD Example

JSX
import { useState } from 'react'

let nextId = 4

function ShoppingList() {
  const [items, setItems] = useState([
    { id: 1, name: 'Apples', qty: 3 },
    { id: 2, name: 'Bread', qty: 1 },
    { id: 3, name: 'Milk', qty: 2 },
  ])

  // CREATE
  function addItem(name) {
    setItems(prev => [...prev, { id: nextId++, name, qty: 1 }])
  }

  // UPDATE
  function updateQty(id, qty) {
    setItems(prev =>
      prev.map((item) => item.id === id ? { ...item, qty } : item)
    )
  }

  // DELETE
  function removeItem(id) {
    setItems(prev => prev.filter((item) => item.id !== id))
  }

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          {item.name}
          <input
            type="number"
            value={item.qty}
            min={1}
            onChange={(e) => updateQty(item.id, Number(e.target.value))}
          />
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  )
}
  • Never call mutating methods (push, pop, splice, sort, reverse) directly on state arrays

  • Add items with [...prev, newItem] or [newItem, ...prev]

  • Remove items with filter

  • Update items with map — return a new object for the changed item, the original for all others

  • Sort/reverse by spreading first: [...prev].sort(...)

  • Immer (use-immer) is worth it for complex array-of-objects state