Skip to content
aviral gupta

// B5.4 · ~30 min · Beginner

Preserving and resetting state

After this lesson you can predict when React keeps a component's state and when it throws it away, and reset it on purpose with a key.

Lesson 4 of 6 in B5 Managing state

You will be able to

  • Explain that React ties state to a position in the render tree, not to a JSX tag
  • Predict when a change keeps state and when a different type or structure resets it
  • Reset state on purpose with different positions or a key, and keep component definitions at the top level
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B5.3: lifted state. Panel owns count and passes it to two Badges. You click Add twice. What do the two <p> show?

    function Badge({ count }) {
      return <p>Count: {count}</p>;
    }
    
    function Panel() {
      const [count, setCount] = useState(0);
      return (
        <>
          <Badge count={count} />
          <Badge count={count} />
          <button onClick={() => setCount(count + 1)}>Add</button>
        </>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. Mia clicks Add one twice, then someone clicks Next player. What does the heading show?

    function Counter({ person }) {
      const [score, setScore] = useState(0);
      return (
        <>
          <h1>{person}'s score: {score}</h1>
          <button onClick={() => setScore(score + 1)}>Add one</button>
        </>
      );
    }
    
    function Scoreboard() {
      const [isPlayerA, setIsPlayerA] = useState(true);
      return (
        <div>
          {isPlayerA ? <Counter person="Mia" /> : <Counter person="Leo" />}
          <button onClick={() => setIsPlayerA(!isPlayerA)}>Next player</button>
        </div>
      );
    }
  3. Practice · Activity 3 of 7

    Scoreboard computes const person = isPlayerA ? 'Mia' : 'Leo'; and renders one Counter. Fill in the attribute that gives each player a fresh score.

    <Counter ____={person} person={person} />
    <Counter ={person} person={person} />
  4. Practice · Activity 4 of 7

    Match each change at a position with what happens to the state there.

  5. Practice · Activity 5 of 7

    App has two return statements. You click the counter twice, then Fancy. What do the buttons show?

    function Counter() {
      const [score, setScore] = useState(0);
      return <button onClick={() => setScore(score + 1)}>Score: {score}</button>;
    }
    
    function App() {
      const [isFancy, setIsFancy] = useState(false);
      if (isFancy) {
        return (
          <div>
            <Counter />
            <button onClick={() => setIsFancy(false)}>Plain</button>
          </div>
        );
      }
      return (
        <div>
          <Counter />
          <button onClick={() => setIsFancy(true)}>Fancy</button>
        </div>
      );
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. NameField is declared inside Form. You type Ada into the field, then click the button. What do the field and the button show?

    function Form() {
      const [count, setCount] = useState(0);
    
      function NameField() {
        const [text, setText] = useState('');
        return <input value={text} onChange={e => setText(e.target.value)} />;
      }
    
      return (
        <>
          <NameField />
          <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: a quiz that starts each question with an empty answer. In main.jsx, write an Answer component that shows a question's text and an input whose value is its own answer state. Write a Quiz component that keeps the index of the current question in state, renders one Answer for it and a Next question button. Without a key, the typed answer would stay for the next question; give Answer a key, so that every question starts empty.

    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 scoreboard where each player starts from zero

The two Counters sit at the same place in the JSX, but each has its own key, Mia or Leo. React uses the key as part of the position, so switching players removes one Counter with its state and creates the other from scratch. A run only mounts, so timers at the bottom of main.jsx click the buttons, one step every 50 ms, and print the heading after each change. In the browser the page stays live: click yourself.

main.jsx

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA ? (
        <Counter key="Mia" person="Mia" />
      ) : (
        <Counter key="Leo" person="Leo" />
      )}
      <button onClick={() => setIsPlayerA(!isPlayerA)}>Next player</button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  return (
    <section>
      <h1>{person}'s score: {score}</h1>
      <button onClick={() => setScore(score + 1)}>Add one</button>
    </section>
  );
}

// A run only mounts. Act like the players, one step every 50 ms.
const click = name => [...document.querySelectorAll('button')].find(b => b.textContent === name).click();
const print = () => console.log(document.querySelector('h1').textContent);
const plan = [
  () => click('Add one'),
  () => click('Add one'),
  () => click('Add one'),
  print,
  () => click('Next player'),
  print,
  () => click('Add one'),
  print,
  () => click('Next player'),
  print,
];
plan.forEach((step, i) => setTimeout(step, 50 * (i + 1)));

Run it with

npm run dev

Output

Mia's score: 3
Leo's score: 0
Leo's score: 1
Mia's score: 0
<!-- #root -->
<div id="root"><div><section><h1>Mia's score: 0</h1><button>Add one</button></section><button>Next player</button></div></div>
  • Mia scored 3. Next player removed her Counter, and its state with it.
  • Leo's Counter started at 0, although it renders at the same place in the JSX.
  • Switching back created Mia's Counter again, from scratch: 0, not 3. Keeping her score would mean lifting it up into Scoreboard.
  • Without the keys, the ternary would keep one score for both players, as in the activity before.
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

Clear the draft when the recipient changes

Messenger shows a ContactList and a Chat for the chosen contact. Type a message to Mia, then choose Leo: the draft stays, so you could send Mia's message to Leo by accident. The Chat is the same component at the same position, so React keeps its state. Make React create a new Chat for every contact, so that switching always starts with an empty message. Change only how Messenger renders Chat.

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

    A key tells React that two components at the same position are different.

  2. Hint 2

    Use something that identifies the contact, such as its id.

  3. Hint 3

    <Chat key={to.id} contact={to} />

Show a solution

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

import { useState } from 'react';

const contacts = [
  { id: 0, name: 'Mia', email: 'mia@mail.com' },
  { id: 1, name: 'Leo', email: 'leo@mail.com' },
  { id: 2, name: 'Noor', email: 'noor@mail.com' }
];

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat key={to.id} contact={to} />
    </div>
  );
}

function ContactList({ contacts, selectedContact, onSelect }) {
  return (
    <ul>
      {contacts.map(contact => (
        <li key={contact.id}>
          <button
            aria-pressed={contact.id === selectedContact.id}
            onClick={() => onSelect(contact)}
          >
            {contact.name}
          </button>
        </li>
      ))}
    </ul>
  );
}

function Chat({ contact }) {
  const [text, setText] = useState('');
  return (
    <section>
      <textarea
        aria-label={'Message to ' + contact.name}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button>Send to {contact.email}</button>
    </section>
  );
}
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 contacts = [
  { id: 0, name: 'Mia', email: 'mia@mail.com' },
  { id: 1, name: 'Leo', email: 'leo@mail.com' },
  { id: 2, name: 'Noor', email: 'noor@mail.com' }
];

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat contact={to} />
    </div>
  );
}

function ContactList({ contacts, selectedContact, onSelect }) {
  return (
    <ul>
      {contacts.map(contact => (
        <li key={contact.id}>
          <button
            aria-pressed={contact.id === selectedContact.id}
            onClick={() => onSelect(contact)}
          >
            {contact.name}
          </button>
        </li>
      ))}
    </ul>
  );
}

function Chat({ contact }) {
  const [text, setText] = useState('');
  return (
    <section>
      <textarea
        aria-label={'Message to ' + contact.name}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button>Send to {contact.email}</button>
    </section>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, fireEvent} from '@testing-library/react';
import Messenger from './main.jsx';

const message = (name) => screen.getByRole('textbox', {name: 'Message to ' + name});
const contact = (name) => screen.getByRole('button', {name});

test('A typed message stays while the contact stays', () => {
  render(<Messenger />);
  fireEvent.change(message('Mia'), {target: {value: 'Hi Mia'}});
  assert.equal(message('Mia').value, 'Hi Mia', 'the message to Mia holds ' + JSON.stringify(message('Mia').value));
});

test('Choosing another contact clears the draft', () => {
  render(<Messenger />);
  fireEvent.change(message('Mia'), {target: {value: 'Hi Mia'}});
  fireEvent.click(contact('Leo'));
  assert.equal(message('Leo').value, '', 'after choosing Leo the message holds ' + JSON.stringify(message('Leo').value));
});

test('Every switch starts with an empty message', () => {
  render(<Messenger />);
  fireEvent.click(contact('Noor'));
  fireEvent.change(message('Noor'), {target: {value: 'Lunch?'}});
  fireEvent.click(contact('Mia'));
  assert.equal(message('Mia').value, '', 'after switching from Noor to Mia the message holds ' + JSON.stringify(message('Mia').value));
  assert.equal(screen.getByRole('button', {name: /^Send to/}).textContent, 'Send to mia@mail.com', 'the send button reads ' + JSON.stringify(screen.getByRole('button', {name: /^Send to/}).textContent));
});

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

Keep the scores with their players

Scoreboard shows Mia and Leo, and Swap changes their order. Give Mia two points and click Swap: Leo now has her 2 points and Mia has 0, because the state stays at its position while the people move. Fix it so that each score stays with its player, however often you swap. Keep the two branches and the Swap button.

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

    Without keys React matches the two Counters by their order within the parent.

  2. Hint 2

    Keys are not only for lists: they work on any components with the same parent.

  3. Hint 3

    Give each Counter its player as the key, in both branches: <Counter key="Mia" person="Mia" />.

Show a solution

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

import { useState } from 'react';

export default function Scoreboard() {
  const [reversed, setReversed] = useState(false);
  return (
    <>
      {reversed ? (
        <>
          <Counter key="Leo" person="Leo" />
          <Counter key="Mia" person="Mia" />
        </>
      ) : (
        <>
          <Counter key="Mia" person="Mia" />
          <Counter key="Leo" person="Leo" />
        </>
      )}
      <button onClick={() => setReversed(!reversed)}>Swap</button>
    </>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  return (
    <section>
      <h2>{person}: {score}</h2>
      <button aria-label={'Add one for ' + person} onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </section>
  );
}
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';

export default function Scoreboard() {
  const [reversed, setReversed] = useState(false);
  return (
    <>
      {reversed ? (
        <>
          <Counter person="Leo" />
          <Counter person="Mia" />
        </>
      ) : (
        <>
          <Counter person="Mia" />
          <Counter person="Leo" />
        </>
      )}
      <button onClick={() => setReversed(!reversed)}>Swap</button>
    </>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  return (
    <section>
      <h2>{person}: {score}</h2>
      <button aria-label={'Add one for ' + person} onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </section>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, fireEvent} from '@testing-library/react';
import Scoreboard from './main.jsx';

const scores = () => screen.getAllByRole('heading').map((h) => h.textContent).join(', ');
const add = (person) => fireEvent.click(screen.getByRole('button', {name: 'Add one for ' + person}));
const swap = () => fireEvent.click(screen.getByRole('button', {name: 'Swap'}));

test('Each counter adds to its own score', () => {
  render(<Scoreboard />);
  add('Mia');
  add('Mia');
  add('Leo');
  assert.equal(scores(), 'Mia: 2, Leo: 1', 'after two points for Mia and one for Leo the scores read ' + JSON.stringify(scores()));
});

test('After a swap, the scores stay with their players', () => {
  render(<Scoreboard />);
  add('Mia');
  add('Mia');
  swap();
  assert.equal(scores(), 'Leo: 0, Mia: 2', 'after two points for Mia and a swap the scores read ' + JSON.stringify(scores()));
});

test('Scores survive two swaps and more clicks', () => {
  render(<Scoreboard />);
  add('Mia');
  add('Mia');
  swap();
  add('Leo');
  assert.equal(scores(), 'Leo: 1, Mia: 2', 'after a swap and one point for Leo the scores read ' + JSON.stringify(scores()));
  swap();
  assert.equal(scores(), 'Mia: 2, Leo: 1', 'after swapping back the scores read ' + JSON.stringify(scores()));
});

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

Relying on state from the previous contact

import { useState } from 'react';

const chats = {
  Mia: [
    { id: 0, text: 'See you at six?' },
    { id: 1, text: 'Bring the map.' },
    { id: 2, text: 'Thanks!' }
  ],
  Leo: [{ id: 0, text: 'Running late.' }]
};

export default function Messenger() {
  const [name, setName] = useState('Mia');
  return (
    <>
      <button onClick={() => setName('Mia')}>Mia</button>
      <button onClick={() => setName('Leo')}>Leo</button>
      <Thread messages={chats[name]} />
    </>
  );
}

function Thread({ messages }) {
  const [index, setIndex] = useState(0);
  return (
    <section>
      <p>{messages[index].text}</p>
      <button onClick={() => setIndex(index + 1)}>Older</button>
    </section>
  );
}

// A run only mounts. Act like a user: read Mia's third message, then open Leo.
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Older').click(), 50);
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Older').click(), 100);
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Leo').click(), 150);

What React prints

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

Why, and the fix

Thread stays at the same position, so its index 2 survives the switch, and Leo has only one message: messages[2] is undefined (Chrome and Node wording; Firefox and Safari word it differently). Give each conversation its own Thread: <Thread key={name} messages={chats[name]} />, so that index starts at 0 again.

Calling a component as a function

import { useState } from 'react';

export default function Scoreboard() {
  const [showLeo, setShowLeo] = useState(false);
  return (
    <div>
      {Counter({ person: 'Mia' })}
      {showLeo && Counter({ person: 'Leo' })}
      <button onClick={() => setShowLeo(true)}>Add Leo</button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  return <h1>{person}: {score}</h1>;
}

// A run only mounts. Act like a user: add Leo.
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Add Leo').click(), 50);

What React prints

Rendered more hooks than during the previous render.

Why, and the fix

Counter({ person: 'Mia' }) is a plain function call, not a component at a position, so its useState belongs to Scoreboard. Adding Leo adds a Hook to Scoreboard, and React stops. Render components with JSX, <Counter person="Mia" />, so that each gets its own position and its own state.

A reset state that starts from a missing prop

import { useState } from 'react';

const contacts = [
  { id: 0, name: 'Mia', draft: 'See you at six?' },
  { id: 1, name: 'Leo' }
];

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <>
      {contacts.map(contact => (
        <button key={contact.id} onClick={() => setTo(contact)}>
          {contact.name}
        </button>
      ))}
      <Chat key={to.id} contact={to} />
    </>
  );
}

function Chat({ contact }) {
  const [text, setText] = useState(contact.draft);
  return (
    <section>
      <textarea value={text} onChange={e => setText(e.target.value)} />
      <p>{text.length} / 280</p>
    </section>
  );
}

// A run only mounts. Act like a user: open Leo.
setTimeout(() => [...document.querySelectorAll('button')].find(b => b.textContent === 'Leo').click(), 50);

What React prints

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

Why, and the fix

The key works: choosing Leo creates a new Chat, and its useState runs again. But Leo has no draft, so text starts as undefined, and text.length throws (Chrome and Node wording; Firefox and Safari word it differently). Make the initial value valid for every contact: useState(contact.draft ?? '').

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

State belongs to a position in the tree

State is not stored in the JSX tag or in the component function. React holds it and ties it to where the component sits in the render tree. Two <Counter /> side by side are two positions, so each gets its own score, even if both come from one JSX variable. When a component stops being rendered, React destroys its state; when it comes back, its state starts from scratch. As long as the same component is rendered at the same position, React keeps the state, whatever props it gets. Two return statements in an if still give the same position if the tree they return has the same shape.

A different type at the same position resets the subtree

If a <p> replaces the <Counter> at a position, React removes the Counter and destroys its state. The same happens to everything below: if a <section> that holds a Counter turns into a <div>, the whole subtree is rebuilt and the Counter starts at 0. To keep state between renders, the structure of the tree must match from one render to the next. This is also why component definitions must not be nested: a component function declared inside another is a new function on every render, so React sees a different type at that position and resets its state each time.

Reset on purpose: different positions or a key

{isPlayerA ? <Counter person="Mia" /> : <Counter person="Leo" />} keeps one score for both players, because both are the same component at the same position. To reset it, render them at two positions, {isPlayerA && …}{!isPlayerA && …}, or give each an identity with a key: <Counter key="Mia" … />. React then uses the key as part of the position instead of the order within the parent. Keys only need to be unique among siblings. A key is the usual way to reset a form: <Chat key={to.id} contact={to} /> clears the draft when the recipient changes. Keeping a removed chat's draft means lifting it up, which is not covered here.

Sources

Last reviewed October 5, 2026