Skip to content
aviral gupta

// B5.2 · ~30 min · Beginner

Choosing the state structure

After this lesson you can choose state that is easy to update: grouped where it changes together, without contradictions, redundant copies or deep nesting.

Lesson 2 of 6 in B5 Managing state

You will be able to

  • Group state that changes together, and replace booleans that can contradict with one status
  • Remove redundant and duplicate state: compute values during render and keep a selected id, not the object
  • Avoid mirroring props in state, and flatten deeply nested state into a table of ids
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B5.1: a status string. isEditing is computed from status. You click the button three times. What does it show?

    function Profile() {
      const [status, setStatus] = useState('viewing');
      const isEditing = status === 'editing';
    
      return (
        <button onClick={() => setStatus(isEditing ? 'viewing' : 'editing')}>
          {isEditing ? 'Save' : 'Edit'} Profile
        </button>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. Menu keeps the picked item as an object. Rename changes the first item in the list. You click it once. What do the list and the <p> show? (list / <p>)

    function Menu() {
      const [items, setItems] = useState([
        { id: 0, title: 'pretzels' },
        { id: 1, title: 'granola bar' }
      ]);
      const [selectedItem, setSelectedItem] = useState(items[0]);
    
      function handleRename() {
        setItems(items.map(item =>
          item.id === 0 ? { ...item, title: 'salted pretzels' } : item
        ));
      }
    
      return (
        <>
          <ul>
            {items.map(item => <li key={item.id}>{item.title}</li>)}
          </ul>
          <p>You picked {selectedItem.title}.</p>
          <button onClick={handleRename}>Rename</button>
        </>
      );
    }
  3. Practice · Activity 3 of 7

    Only selectedId is in state. Fill in the array method that gets the selected item from items during render.

    const selectedItem = items.____(item => item.id === selectedId);
    const selectedItem = items.(item => item.id === selectedId);
  4. Practice · Activity 4 of 7

    Match each principle from react.dev with the change it suggests.

  5. Practice · Activity 5 of 7

    fullName is kept in state, and only the first handler updates it. You type Ada into the first field, then Lovelace into the second. What does the <p> show (spaces at the ends left out)?

    function Ticket() {
      const [firstName, setFirstName] = useState('');
      const [lastName, setLastName] = useState('');
      const [fullName, setFullName] = useState('');
    
      function handleFirstNameChange(e) {
        setFirstName(e.target.value);
        setFullName(e.target.value + ' ' + lastName);
      }
    
      function handleLastNameChange(e) {
        setLastName(e.target.value);
      }
    
      return (
        <>
          <input value={firstName} onChange={handleFirstNameChange} />
          <input value={lastName} onChange={handleLastNameChange} />
          <p>Ticket for: {fullName}</p>
        </>
      );
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Message copies its messageColor prop into state. App passes blue first. You click Red once. What do the two <p> show?

    function Message({ messageColor }) {
      const [color] = useState(messageColor);
      return <p>Message: {color}</p>;
    }
    
    function App() {
      const [color, setColor] = useState('blue');
      return (
        <>
          <button onClick={() => setColor('red')}>Red</button>
          <p>Picked: {color}</p>
          <Message messageColor={color} />
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: a feedback form without contradictions. In main.jsx, write a FeedbackForm with a textarea and a Send button. Pretend to send with a sendMessage(text) function that resolves after 50 ms. Instead of two booleans, isSending and isSent, keep one status in state: 'typing', 'sending' or 'sent'. Name its parts with constants computed from status. While sending, disable both controls and show Sending...; once sent, show Thanks for feedback! instead of the form.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A flat travel plan

The plan is not a tree of nested objects. It is a table: each place has an id, a title and the ids of its child places, and plan maps each id to its place. PlaceTree finds a place by id and renders its children the same way. Completing a place updates only two levels: a new parent without that child id, and a new table that holds the new parent. A run only mounts, so timers at the bottom of main.jsx complete Kenya, then Europe. In the browser the page stays live: click Complete yourself.

main.jsx

import { useState } from 'react';

const initialPlan = {
  0: { id: 0, title: '(Root)', childIds: [1, 4] },
  1: { id: 1, title: 'Africa', childIds: [2, 3] },
  2: { id: 2, title: 'Egypt', childIds: [] },
  3: { id: 3, title: 'Kenya', childIds: [] },
  4: { id: 4, title: 'Europe', childIds: [5] },
  5: { id: 5, title: 'France', childIds: [] },
};

export default function TravelPlan() {
  const [plan, setPlan] = useState(initialPlan);

  function handleComplete(parentId, childId) {
    const parent = plan[parentId];
    // A new version of the parent place
    // that doesn't include this child ID.
    const nextParent = {
      ...parent,
      childIds: parent.childIds.filter(id => id !== childId)
    };
    // A new table with the updated parent.
    setPlan({
      ...plan,
      [parentId]: nextParent
    });
  }

  const root = plan[0];
  return (
    <>
      <h2>Places to visit</h2>
      <ol>
        {root.childIds.map(id => (
          <PlaceTree
            key={id}
            id={id}
            parentId={0}
            placesById={plan}
            onComplete={handleComplete}
          />
        ))}
      </ol>
    </>
  );
}

function PlaceTree({ id, parentId, placesById, onComplete }) {
  const place = placesById[id];
  const childIds = place.childIds;
  return (
    <li>
      {place.title}
      <button onClick={() => onComplete(parentId, id)}>Complete</button>
      {childIds.length > 0 && (
        <ol>
          {childIds.map(childId => (
            <PlaceTree
              key={childId}
              id={childId}
              parentId={id}
              placesById={placesById}
              onComplete={onComplete}
            />
          ))}
        </ol>
      )}
    </li>
  );
}

// A run only mounts. Act like a user: complete Kenya, then Europe.
function completeButton(title) {
  const li = [...document.querySelectorAll('li')].find(li => li.firstChild.textContent === title);
  return li.querySelector('button');
}

setTimeout(() => {
  completeButton('Kenya').click();
  setTimeout(() => {
    completeButton('Europe').click();
    setTimeout(() => {
      console.log('initialPlan[1].childIds is still', initialPlan[1].childIds.join(', '));
    }, 50);
  }, 50);
}, 50);

Run it with

npm run dev

Output

initialPlan[1].childIds is still 2, 3
<!-- #root -->
<div id="root"><h2>Places to visit</h2><ol><li>Africa<button>Complete</button><ol><li>Egypt<button>Complete</button></li></ol></li></ol></div>
  • Completing Kenya made a new Africa with childIds [2]; the table and the other places were copied as they were.
  • Completing Europe removed it from the root's childIds, so France disappeared with it: nothing renders a place that no parent lists.
  • initialPlan was never changed: every update made new objects.
  • Kenya, Europe and France are still in the table. react.dev shows how to delete them too, to save memory.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 2

Fix the stale selection

Menu lets you pick a travel snack and edit the snack names. Choose a snack, then edit its name: the input changes, but You picked … keeps the old name, because the picked snack is kept as a copy of the object. Remove the duplication. Keep only selectedId in state, starting at 0, and find the selected item in items during render. Choose and the editing must keep working.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Replace the selectedItem state with const [selectedId, setSelectedId] = useState(0);.

  2. Hint 2

    Choose calls setSelectedId(item.id).

  3. Hint 3

    In the component body: const selectedItem = items.find(item => item.id === selectedId);.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

const initialItems = [
  { title: 'pretzels', id: 0 },
  { title: 'crispy seaweed', id: 1 },
  { title: 'granola bar', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedId, setSelectedId] = useState(0);

  const selectedItem = items.find(item => item.id === selectedId);

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return { ...item, title: e.target.value };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>What's your travel snack?</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              aria-label={'Snack ' + (index + 1)}
              value={item.title}
              onChange={e => handleItemChange(item.id, e)}
            />{' '}
            <button onClick={() => setSelectedId(item.id)}>Choose</button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

const initialItems = [
  { title: 'pretzels', id: 0 },
  { title: 'crispy seaweed', id: 1 },
  { title: 'granola bar', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedItem, setSelectedItem] = useState(items[0]);

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return { ...item, title: e.target.value };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>What's your travel snack?</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              aria-label={'Snack ' + (index + 1)}
              value={item.title}
              onChange={e => handleItemChange(item.id, e)}
            />{' '}
            <button onClick={() => setSelectedItem(item)}>Choose</button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Menu from './main.jsx';

const picked = () => screen.getByText(/^You picked/).textContent;
const choose = (n) => screen.getAllByRole('button', {name: 'Choose'})[n];
const snack = (n) => screen.getByRole('textbox', {name: 'Snack ' + n});

test('Choose picks an item', async () => {
  const user = userEvent.setup();
  render(<Menu />);
  assert.equal(picked(), 'You picked pretzels.', 'at the start the message reads ' + JSON.stringify(picked()));
  await user.click(choose(1));
  assert.equal(picked(), 'You picked crispy seaweed.', 'after choosing the second snack the message reads ' + JSON.stringify(picked()));
});

test('Editing the chosen item updates the message', async () => {
  const user = userEvent.setup();
  render(<Menu />);
  await user.click(choose(1));
  await user.clear(snack(2));
  await user.type(snack(2), 'nori');
  assert.equal(picked(), 'You picked nori.', 'after editing the chosen snack the message reads ' + JSON.stringify(picked()));
});

test('Editing the first item before choosing updates the message', async () => {
  const user = userEvent.setup();
  render(<Menu />);
  await user.type(snack(1), ' sticks');
  assert.equal(picked(), 'You picked pretzels sticks.', 'after editing the first snack the message reads ' + JSON.stringify(picked()));
});

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Exercise 2 of 2

A total that cannot go out of sync

ShoppingList keeps the items and their total in two state variables. Add milk updates both, but Remove forgets the total, so it goes out of sync. Do not patch handleRemove: the total is redundant. Remove the total state and compute it during render from items, so every change to the list shows up in Total: … € without extra code.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Delete const [total, setTotal] = useState(12); and the setTotal call in handleAdd.

  2. Hint 2

    Add the total back as a plain constant in the component body.

  3. Hint 3

    const total = items.reduce((sum, item) => sum + item.price, 0);

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

let nextId = 3;
const initialItems = [
  { id: 0, name: 'Bread', price: 3 },
  { id: 1, name: 'Cheese', price: 5 },
  { id: 2, name: 'Apples', price: 4 },
];

export default function ShoppingList() {
  const [items, setItems] = useState(initialItems);
  const total = items.reduce((sum, item) => sum + item.price, 0);

  function handleAdd() {
    setItems([...items, { id: nextId++, name: 'Milk', price: 2 }]);
  }

  function handleRemove(id) {
    setItems(items.filter(item => item.id !== id));
  }

  return (
    <>
      <button onClick={handleAdd}>Add milk</button>
      <ul>
        {items.map(item => (
          <li key={item.id}>
            {item.name}: {item.price} €{' '}
            <button aria-label={'Remove ' + item.name} onClick={() => handleRemove(item.id)}>
              Remove
            </button>
          </li>
        ))}
      </ul>
      <p>Total: {total} €</p>
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

let nextId = 3;
const initialItems = [
  { id: 0, name: 'Bread', price: 3 },
  { id: 1, name: 'Cheese', price: 5 },
  { id: 2, name: 'Apples', price: 4 },
];

export default function ShoppingList() {
  const [items, setItems] = useState(initialItems);
  // TODO: total goes out of sync when an item is removed.
  const [total, setTotal] = useState(12);

  function handleAdd() {
    setItems([...items, { id: nextId++, name: 'Milk', price: 2 }]);
    setTotal(total + 2);
  }

  function handleRemove(id) {
    setItems(items.filter(item => item.id !== id));
  }

  return (
    <>
      <button onClick={handleAdd}>Add milk</button>
      <ul>
        {items.map(item => (
          <li key={item.id}>
            {item.name}: {item.price} €{' '}
            <button aria-label={'Remove ' + item.name} onClick={() => handleRemove(item.id)}>
              Remove
            </button>
          </li>
        ))}
      </ul>
      <p>Total: {total} €</p>
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ShoppingList from './main.jsx';

const total = () => screen.getByText(/^Total:/).textContent;

test('The total starts at the sum of the prices', () => {
  render(<ShoppingList />);
  assert.equal(total(), 'Total: 12 €', 'at the start the total reads ' + JSON.stringify(total()));
});

test('Removing an item lowers the total', async () => {
  const user = userEvent.setup();
  render(<ShoppingList />);
  await user.click(screen.getByRole('button', {name: 'Remove Cheese'}));
  assert.equal(total(), 'Total: 7 €', 'after removing Cheese the total reads ' + JSON.stringify(total()));
});

test('Adding milk and removing bread keep the total right', async () => {
  const user = userEvent.setup();
  render(<ShoppingList />);
  await user.click(screen.getByRole('button', {name: 'Add milk'}));
  assert.equal(total(), 'Total: 14 €', 'after adding milk the total reads ' + JSON.stringify(total()));
  await user.click(screen.getByRole('button', {name: 'Remove Bread'}));
  assert.equal(total(), 'Total: 11 €', 'after removing Bread the total reads ' + JSON.stringify(total()));
});

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Common mistakes

Updating one field of a grouped object

import { useState } from 'react';

export default function Dot() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  return (
    <>
      <p>x: {position.x.toFixed(1)}, y: {position.y.toFixed(1)}</p>
      <button onClick={() => setPosition({ x: 100 })}>Move right</button>
    </>
  );
}

// A run only mounts. Act like a user: click once.
setTimeout(() => document.querySelector('button').click(), 50);

What React prints

TypeError: Cannot read properties of undefined (reading 'toFixed')

Why, and the fix

setPosition({ x: 100 }) replaces the whole object, so the new position has no y, and the render throws at position.y.toFixed (Chrome and Node wording; Firefox and Safari word it differently). Copy the other field: setPosition({ ...position, x: 100 }), or keep x and y in two state variables if they change separately.

Deleting the item whose id is selected

import { useState } from 'react';

export default function Menu() {
  const [items, setItems] = useState([
    { id: 0, title: 'pretzels' },
    { id: 1, title: 'granola bar' }
  ]);
  const [selectedId, setSelectedId] = useState(0);

  const selectedItem = items.find(item => item.id === selectedId);

  return (
    <>
      <ul>
        {items.map(item => (
          <li key={item.id}>
            {item.title}
            <button onClick={() => setItems(items.filter(i => i.id !== item.id))}>
              Delete
            </button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

// A run only mounts. Act like a user: delete the picked item.
setTimeout(() => document.querySelector('li button').click(), 50);

What React prints

TypeError: Cannot read properties of undefined (reading 'title')

Why, and the fix

find returns undefined when no item has selectedId, and the render throws at selectedItem.title (Chrome and Node wording; Firefox and Safari word it differently). Handle the missing item: {selectedItem ? <p>You picked {selectedItem.title}.</p> : <p>Pick a snack.</p>}, or clear the selection when you delete it.

Removing a place from the table but not from its parent

import { useState } from 'react';

const initialPlan = {
  0: { id: 0, title: '(Root)', childIds: [1, 2] },
  1: { id: 1, title: 'Kenya', childIds: [] },
  2: { id: 2, title: 'Peru', childIds: [] },
};

export default function Plan() {
  const [plan, setPlan] = useState(initialPlan);

  function handleComplete(id) {
    // Removes the place from the table only.
    const nextPlan = { ...plan };
    delete nextPlan[id];
    setPlan(nextPlan);
  }

  return (
    <ol>
      {plan[0].childIds.map(id => (
        <Place key={id} id={id} plan={plan} onComplete={handleComplete} />
      ))}
    </ol>
  );
}

function Place({ id, plan, onComplete }) {
  const place = plan[id];
  const childIds = place.childIds;
  return (
    <li>
      {place.title} ({childIds.length} stops)
      <button onClick={() => onComplete(id)}>Complete</button>
    </li>
  );
}

// A run only mounts. Act like a user: complete Kenya.
setTimeout(() => document.querySelector('button').click(), 50);

What React prints

TypeError: Cannot read properties of undefined (reading 'childIds')

Why, and the fix

The root still lists the id, so Place looks it up in a table that no longer has it and throws at place.childIds (Chrome and Node wording; Firefox and Safari word it differently). Update the parent first: a new root whose childIds filter out the id, then a new table with that root.

React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Group what changes together, and rule out contradictions

If two state variables always update together, such as the x and y of a pointer, merge them into one object, so you cannot forget one. An object in state is replaced whole, though: setPosition({ x: 100 }) loses y, so copy the rest with { ...position, x: 100 }. Booleans that must never be true together, such as isSending and isSent, leave room for an impossible state. Replace them with one status, 'typing', 'sending' or 'sent'. You can still name parts of it, const isSending = status === 'sending': those constants are not state, so they cannot get out of sync.

Keep only what you cannot compute

If you can calculate something from props or other state during rendering, do not put it in state. fullName is firstName + ' ' + lastName, so compute it in the component body; every set call renders again, and the new value follows. A total is a reduce over the items. Duplication is the same problem in another form: keeping the selected item as an object copies one of the items, and editing the list leaves the copy stale. Keep selectedId in state and find the item during render. Do not copy a prop into state either: useState uses it only on the first render. Name such a prop initialColor if ignoring updates is the point.

Flatten deeply nested state

A tree of places, each with an array of child places, is natural to write and hard to update: removing a deep place means copying every parent above it. Make it flat, or normalized, like a database table: one object that maps each id to a place, and each place lists only its childIds. Removing a place then updates two levels: a new parent whose childIds no longer has the id, and a new table that holds that parent. Ephemeral UI state, such as whether one item is hovered, can often move into the child component instead.

Sources

Last reviewed October 5, 2026