React State and Immutable Updates

React & Next.js: The Complete Developer HandbookLesson 5 of 54

Part 1: React Foundations · 5 of 16

Goal: model data that changes while someone uses the app with useState, update arrays and objects by replacing them instead of changing them in place, and keep state minimal by calculating everything else from it.

Until now the tool library has mostly displayed fixed data. The borrowing basket is different: members add tools, remove them and hit the three-tool limit, and the header, the buttons and the warning messages must all agree at every moment. That is the job of state. This lesson covers how to hold it, how to update it safely and how to keep so little of it that the screen can never contradict itself.

What state is for

State is data a component remembers between renders and that, when it changes, should change the screen. An ordinary variable can do neither. Run this and click the button a few times:

import { useState } from 'react';

function BasketCounter() {
  let plainCount = 0;
  const [stateCount, setStateCount] = useState(0);

  function handleAdd() {
    plainCount = plainCount + 1;
    setStateCount(stateCount + 1);
    console.log('plainCount after this click:', plainCount);
  }

  return (
    <div>
      <p>Plain variable: {plainCount}</p>
      <p>State: {stateCount}</p>
      <button onClick={handleAdd}>Add a tool</button>
    </div>
  );
}

The state line counts up. The plain variable stays at 0 on screen, and the console says 1 after every click. Two separate problems cause that. First, plainCount is created afresh, as 0, each time React calls BasketCounter, so nothing carries over from one render to the next. Second, changing a local variable doesn’t tell React anything; if the handler changed only plainCount, React would not render again at all. useState solves both: React stores the value outside your function, hands back the current value on each render, and the setter asks for a new render.

A few facts about useState worth knowing early:

  • The argument is the initial value. React uses it on the first render only and ignores it afterwards. If computing it is expensive (reading and parsing saved data, say), pass a function, useState(() => loadSavedBasket()), and React calls it once instead of on every render.
  • State belongs to one instance of a component. Render <BasketCounter /> twice and each copy keeps its own count.
  • Hooks are called at the top level of the component, never inside a condition, loop or nested function. React matches each useState call to its stored value by the order of the calls, so that order must be the same on every render.

Updater functions and the update queue

Lesson 1 showed that calling setDays(days + 7) twice adds 7, not 14, because days is fixed for the whole render. It helps to know what React does with the calls. Each setter call during an event is put in a queue; React works through the queue in order when it renders next. A plain value means “replace the state with this”. A function means “here is how to get the next state from the one before”, and React passes it the result of the previous item in the queue:

// count is 0 in this render
setCount(5);               // queue: replace with 5      -> 5
setCount((c) => c + 1);    // queue: take 5, add 1       -> 6

// count is 0 in this render
setCount((c) => c + 1);    // take 0, add 1              -> 1
setCount(5);               // replace with 5             -> 5

The first pair ends at 6, the second at 5. React renders once after the handler finishes, with the final result; it doesn’t render between the calls. This is called batching, and since React 18 it also applies to updates made in timeouts and after await.

The case where updaters matter most in real code is not two calls in a row, but a handler that waits. Imagine the basket checks availability before adding a tool. Click the button once and wait, then click it three times quickly:

import { useState } from 'react';

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function QuickAdd() {
  const [count, setCount] = useState(0);
  const [safeCount, setSafeCount] = useState(0);

  async function handleAdd() {
    await wait(500); // pretend to check availability
    setCount(count + 1);
    setSafeCount((c) => c + 1);
  }

  return (
    <div>
      <p>Without an updater: {count}</p>
      <p>With an updater: {safeCount}</p>
      <button onClick={handleAdd}>Add a drill</button>
    </div>
  );
}

After a single click both lines say 1. After three quick clicks the first line has gone up by only 1 and the second by 3. All three clicks happened in the same render, so all three handlers saw the same count and each set it to that value plus 1. The updater doesn’t read count at all; it receives whatever the state is when React applies it.

So the rule of thumb: when the next state is calculated from the previous state, use an updater. When you are setting a value that doesn’t depend on the old one, such as the text a member just typed, pass the value directly. Name the parameter after the state (prev, c or prevBasket are all common). Keep updaters pure: they only calculate and return. In Strict Mode, React calls them twice during development to help you catch updaters that do something else, like logging analytics or changing another variable.

Why you must not mutate state

Numbers, strings and booleans can only be replaced. Arrays and objects can be changed in place, with push or draft.name = …, and that is where most state bugs start. When you call a setter, React compares the new value with the current one using Object.is. If you pass the same array you already had, React concludes nothing changed and can skip the render. Click Add tile cutter twice, then the second button:

import { useState } from 'react';

function MutatingBasket() {
  const [basket, setBasket] = useState(['Cordless drill']);
  const [, setTick] = useState(0); // only used to force a render

  function handleAdd() {
    basket.push('Tile cutter'); // changes the array React is holding
    setBasket(basket);          // same array, so React sees no change
  }

  return (
    <div>
      <p>Basket: {basket.join(', ')}</p>
      <p>Items: {basket.length}</p>
      <button onClick={handleAdd}>Add tile cutter</button>
      <button onClick={() => setTick((t) => t + 1)}>Render for another reason</button>
    </div>
  );
}

The first two clicks do nothing visible. The third click changes unrelated state, React renders, and suddenly the basket shows two tile cutters and 3 items. The data was changed all along; the screen just didn’t know. In a real app this shows up as “it only updates when I click something else”.

Mutation also changes the snapshot an older render is still holding, breaks the comparisons memo relies on (Lesson 13), and makes undo impossible because the previous version no longer exists. The fix is always the same: treat state as read-only and give the setter a new array or object. Changing a copy you have just made is fine, because nothing else holds it yet:

// Fine: next is a brand-new array that only this handler knows about.
const next = [...basket];
next.push('Tile cutter');
setBasket(next);

// Shorter, same result.
setBasket((prev) => [...prev, 'Tile cutter']);

Updating arrays

Every common array change has a version that returns a new array and leaves the original alone. Learn these pairs and you will rarely need anything else:

TaskMutates (avoid on state)Returns a new array
Addpush, unshift[...prev, item], [item, ...prev]
Removesplice, pop, shiftfilter, slice
Change one itemprev[i] = x, prev[i].days++map, returning a new object for the changed item
Sort or reversesort, reversetoSorted, toReversed, or [...prev].sort()
Array methods and their non-mutating replacements.

Here are add, remove and change together, on a list of loans that each have a length in days. Try each button:

import { useState } from 'react';

function LoanList() {
  const [loans, setLoans] = useState([
    { toolId: 't1', name: 'Cordless drill', days: 3 },
    { toolId: 't6', name: 'Tile cutter', days: 2 },
  ]);
  const hasStudFinder = loans.some((loan) => loan.toolId === 't7');

  function handleExtend(toolId) {
    setLoans((prev) =>
      prev.map((loan) =>
        loan.toolId === toolId ? { ...loan, days: loan.days + 1 } : loan
      )
    );
  }

  function handleRemove(toolId) {
    setLoans((prev) => prev.filter((loan) => loan.toolId !== toolId));
  }

  function handleAddStudFinder() {
    setLoans((prev) => [...prev, { toolId: 't7', name: 'Stud finder', days: 1 }]);
  }

  return (
    <div>
      <ul>
        {loans.map((loan) => (
          <li key={loan.toolId}>
            {loan.name}: {loan.days} days{' '}
            <button onClick={() => handleExtend(loan.toolId)}>One more day</button>{' '}
            <button onClick={() => handleRemove(loan.toolId)}>Remove</button>
          </li>
        ))}
      </ul>
      <button onClick={handleAddStudFinder} disabled={hasStudFinder}>
        Add stud finder
      </button>
    </div>
  );
}

Clicking One more day next to the drill makes it 4 days and leaves the tile cutter alone. In handleExtend, map builds a new array; for the matching loan it returns a new object with the spread of the old one and a new days; every other loan is returned as it was. Unchanged items can be shared between the old and new arrays, because nobody changes them. What you must not do is loan.days = loan.days + 1 inside the map: the array would be new, but the loan object inside it would be the same one the previous render holds.

toSorted and toReversed (ES2023) work in all current browsers; for older ones, sort a copy made with [...prev]. Lesson 7 sorts the catalogue this way.

Updating objects and nested objects

Objects work the same way: build a new object with the spread syntax and override the fields that change. The catch is that spread is shallow. It copies the top level only; any object inside is still the same object. A reservation draft is a good example:

import { useState } from 'react';

function ReservationDraft() {
  const [reservation, setReservation] = useState({
    toolName: 'Cordless drill',
    member: { name: 'Amina', email: 'amina@example.com' },
    dates: { start: '2026-12-01', end: '2026-12-03' },
  });

  function handleExtend() {
    setReservation((prev) => ({
      ...prev,                                 // new outer object
      dates: { ...prev.dates, end: '2026-12-05' }, // new inner object
    }));
  }

  const { toolName, member, dates } = reservation;
  return (
    <div>
      <p>{toolName} for {member.name}, {dates.start} to {dates.end}</p>
      <button onClick={handleExtend}>Extend to 5 December</button>
    </div>
  );
}

Clicking the button changes the end date to 2026-12-05. The update copies two levels: the reservation, and the dates object inside it, because that is the one that changes. The member object is untouched, so sharing it is fine. The tempting shortcut below is a bug, even though it looks like it makes a copy:

// Wrong: copy.dates is the same object as reservation.dates,
// so this also changes the snapshot the current render is using.
const copy = { ...reservation };
copy.dates.end = '2026-12-05';
setReservation(copy);

It appears to work, because copy is a new object and React renders. But the old state was changed too, so anything comparing old and new dates sees no difference. If you find yourself spreading three or four levels deep, that is a sign the state is too nested. Flatten it (keep startDate and endDate at the top level), or move the update logic into a reducer (Lesson 10). Libraries such as Immer let you write mutating-style code that produces copies; learn the plain version first so you know what they do for you.

Keep state minimal: derive the rest

Every piece of state must be kept correct by every handler that touches it, so keep as little as you can. Before adding useState, ask three questions about the value:

  1. Does it stay the same for the whole life of the component? Then it is a constant, not state.
  2. Does a parent pass it in? Then it is a prop.
  3. Can it be calculated from other state or props? Then calculate it during render. This is a derived value.

Only what is left needs state. Here is what happens when a derived value is stored instead. Add the drill, then remove it:

import { useState } from 'react';

function DriftingBasket() {
  const [basket, setBasket] = useState([]);
  const [count, setCount] = useState(0); // duplicates basket.length

  function handleAdd() {
    setBasket((prev) => [...prev, 'Cordless drill']);
    setCount((c) => c + 1);
  }

  function handleRemove() {
    setBasket((prev) => prev.slice(0, -1));
    // Forgot setCount here.
  }

  return (
    <div>
      <p>Header: Basket ({count})</p>
      <p>Basket: {basket.join(', ') || 'empty'}</p>
      <button onClick={handleAdd}>Add drill</button>
      <button onClick={handleRemove}>Remove last</button>
    </div>
  );
}

After adding, both lines agree. After removing, the basket says “empty” and the header still says Basket (1). One forgotten line, and the app now contradicts itself. With the count derived, that bug cannot exist, because there is nothing to forget:

const [basketIds, setBasketIds] = useState([]);

// Derived during render: always in step with basketIds.
const count = basketIds.length;
const isFull = count >= 3;

The same goes for “is the basket full?”, “which tools match the search?” (Lesson 7) and “is this form valid?” (Lesson 8). Recalculating is cheap for lists the size of a tool catalogue; Lesson 10 covers useMemo for the rare case where a calculation is measurably slow.

Store stable ids, not copies

Deriving applies to data from elsewhere too. The basket could store whole tool objects, [{ id: 't1', name: 'Cordless drill', status: 'available', … }], but then the same tool exists twice: once in the catalogue and once in the basket. When a volunteer marks the drill as in repair, the catalogue is updated and the basket copy still says “available”. Store the tool’s id instead, ['t1', 't6'], and look the tool up when you render. There is then one source of truth for each tool.

For that to work, ids must be stable: the same tool always has the same id. The ids in TOOLS come from the data, which is the best case. Two kinds of “id” are not stable and cause subtle bugs:

  • Array positions. “The tool at index 2” means a different tool as soon as an earlier one is removed or the list is sorted.
  • Values generated during render, such as Math.random(). They change on every render, so nothing can be matched up later.

When your app creates something that has no id yet, such as a note on a loan, create the id once, in the event handler that creates the item, and store it with the item. crypto.randomUUID() does this in modern browsers on HTTPS pages and on localhost; data saved to a server usually gets its id from the server. Lesson 7 shows how the same stable ids become list keys.

Common mistakes

  • Mutating state, then setting it. push, splice, sort, reverse or state.field = … on a value that came from useState. In review, search the component for those methods and check what array they are called on. The symptom is a screen that only updates after some other change.
  • A shallow copy with a nested change. { ...prev } followed by copy.dates.end = …. Every object on the path to the changed field needs its own spread.
  • Storing what can be calculated. A count, isFull or filteredTools state next to the state it comes from. Look for setters that are always called in pairs; one of the two values is probably derived.
  • Calling a setter during render. setCount(count + 1) in the component body, or onClick={setOpen(true)}, which calls it immediately instead of on click. Each render triggers another, and React stops with a “Too many re-renders” error. Setters belong in event handlers (and, in Lesson 9, in effects).
  • Building on a stale value. setCount(count + 1) inside something that waits, or called more than once. Use setCount((c) => c + 1) whenever the new value comes from the old one.

Build it: the borrowing basket

Build the basket members fill before they reserve. The starter shows the available tools and an empty basket. Make it work:

  • Hold the basket in one piece of state: an array of tool ids.
  • Add to basket adds the tool’s id; each basket row has a Remove button; Clear basket empties it.
  • The heading shows “Basket (2 of 3)”. A member can borrow at most 3 tools: when the basket is full, every Add button is disabled and the message “You can borrow up to 3 tools at once.” appears.
  • A tool already in the basket can’t be added twice.
import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill', status: 'available' },
  { id: 't2', name: 'Extension ladder', status: 'on-loan' },
  { id: 't3', name: 'Orbital sander', status: 'available' },
  { id: 't6', name: 'Tile cutter', status: 'available' },
  { id: 't7', name: 'Stud finder', status: 'available' },
];

function BorrowingBasket() {
  // Your state here.
  const availableTools = TOOLS.filter((tool) => tool.status === 'available');

  return (
    <div>
      <h2>Catalogue</h2>
      <ul>
        {availableTools.map((tool) => (
          <li key={tool.id}>
            {tool.name} <button>Add to basket</button>
          </li>
        ))}
      </ul>
      <h2>Basket (0 of 3)</h2>
      <p>Your basket is empty.</p>
    </div>
  );
}

Hint: after useState([]) for the ids, everything else is derived: the tools in the basket (TOOLS.filter with includes), the count, whether it is full, and whether each Add button is disabled. For adding, use an updater that returns prev unchanged if the id is already there or the basket is full.

Show a sample solution
import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill', status: 'available' },
  { id: 't2', name: 'Extension ladder', status: 'on-loan' },
  { id: 't3', name: 'Orbital sander', status: 'available' },
  { id: 't6', name: 'Tile cutter', status: 'available' },
  { id: 't7', name: 'Stud finder', status: 'available' },
];
const MAX_TOOLS = 3;

function BorrowingBasket() {
  const [basketIds, setBasketIds] = useState([]);

  const availableTools = TOOLS.filter((tool) => tool.status === 'available');
  const basketTools = TOOLS.filter((tool) => basketIds.includes(tool.id));
  const isFull = basketIds.length >= MAX_TOOLS;

  function handleAdd(toolId) {
    setBasketIds((prev) =>
      prev.includes(toolId) || prev.length >= MAX_TOOLS ? prev : [...prev, toolId]
    );
  }

  function handleRemove(toolId) {
    setBasketIds((prev) => prev.filter((id) => id !== toolId));
  }

  return (
    <div>
      <h2>Catalogue</h2>
      <ul>
        {availableTools.map((tool) => (
          <li key={tool.id}>
            {tool.name}{' '}
            <button onClick={() => handleAdd(tool.id)}
              disabled={isFull || basketIds.includes(tool.id)}>Add to basket</button>
          </li>
        ))}
      </ul>
      <h2>Basket ({basketIds.length} of {MAX_TOOLS})</h2>
      {isFull && <p>You can borrow up to {MAX_TOOLS} tools at once.</p>}
      {basketTools.length === 0 && <p>Your basket is empty.</p>}
      <ul>
        {basketTools.map((tool) => (
          <li key={tool.id}>
            {tool.name} <button onClick={() => handleRemove(tool.id)}>Remove</button>
          </li>
        ))}
      </ul>
      <button onClick={() => setBasketIds([])} disabled={basketIds.length === 0}>
        Clear basket
      </button>
    </div>
  );
}

One array of ids is the only state; the basket rows, the count, the full message and every disabled button are calculated from it, so they can’t disagree. The guard inside handleAdd looks redundant next to the disabled buttons, but it keeps the rule true even if a future change forgets to disable a button. When the guard returns prev, the setter receives the same array and React skips the render, which is exactly the behaviour you saw in the mutation example, used on purpose this time. The basket lists tools in catalogue order; to show them in the order they were added, build the rows with basketIds.map((id) => TOOLS.find((tool) => tool.id === id)) instead.

Check your understanding

Answer each question in a sentence or two before opening the answer.

1. A teammate sorts the basket with basketIds.sort() and then calls setBasketIds(basketIds). Nothing changes on screen. Why?

sort changes the existing array in place and returns that same array. The setter receives the array React already holds, Object.is says it is unchanged, and React skips the render. Sort a copy instead: setBasketIds((prev) => prev.toSorted()) or [...prev].sort().

2. Why does the basket store tool ids instead of copies of the tool objects?

Copies duplicate data. When the catalogue’s version of a tool changes (its status, say), the copy in the basket goes stale and the two parts of the screen disagree. Storing the id and looking the tool up during render keeps a single source of truth.

3. A member reports that the basket once held four tools. How would you try to reproduce it, and what would you look for in the code?

Try to add tools faster than the screen updates: double-click Add buttons, or add while a slow check is running, and also check any other path that adds tools. In the code, look for an add handler that builds on the render’s basketIds (setBasketIds([...basketIds, id])) instead of an updater, a limit check that only exists as a disabled button rather than in the update itself, or a separately stored count that has drifted from the real basket.

Key takeaways

  • State is data React keeps between renders; setting it schedules a render. Local variables reset on every render and never trigger one.
  • Setter calls are queued and applied in order. Use an updater, setX((prev) => …), whenever the next value is calculated from the previous one, especially after await.
  • Never mutate state. Give the setter a new array ([...prev, x], filter, map, toSorted) or a new object ({ ...prev, field }), copying every level down to the change.
  • Keep state minimal: derive counts, limits and filtered lists during render, and store stable ids rather than copies of other data.

Priority: must understand. Almost every React bug you will review comes down to state that was mutated, duplicated or read at the wrong moment, so you should be able to spot all three on sight. For the exact API, see useState and Updating Arrays in State in the React documentation.

Code examples in this course are free to use under the MIT licence. Lesson text © 2026 Farooq Bin Munir.