Skip to content
aviral gupta

// B3.2 · ~30 min · Beginner

State: a component's memory

After this lesson you replace forgetful local variables with state, give a component several state variables, and know why each copy of a component remembers on its own.

Lesson 2 of 6 in B3 Adding interactivity

You will be able to

  • Explain why a local variable cannot remember a value between renders, and use state instead
  • Declare one or more state variables with useState, following the naming and Hook rules
  • Predict that each rendered instance of a component keeps its own private state
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B3.1: propagation. The Delete button sits inside a list item with its own onClick. You click Delete. What is printed?

    function Row() {
      return (
        <li onClick={() => console.log('row')}>
          <button
            onClick={e => {
              e.stopPropagation();
              console.log('delete');
            }}
          >
            Delete
          </button>
        </li>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. Stepper keeps clicks in state and step in a local variable. You click three times. What does the button show?

    function Stepper() {
      const [clicks, setClicks] = useState(0);
      let step = 0;
    
      function handleClick() {
        step = step + 1;
        setClicks(clicks + 1);
      }
    
      return (
        <button onClick={handleClick}>
          clicks {clicks}, step {step}
        </button>
      );
    }
  3. Practice · Activity 3 of 7

    Fill in the call that flips showMore, so the button shows the details.

    function Details() {
      const [showMore, setShowMore] = useState(false);
    
      function handleMoreClick() {
        ____(!showMore);
      }
    
      return (
        <>
          <button onClick={handleMoreClick}>{showMore ? 'Hide' : 'Show'}</button>
          {showMore && <p>A calm, deep blue-green.</p>}
        </>
      );
    }
    (!showMore);
  4. Practice · Activity 4 of 7

    Match each piece of code to what it is.

  5. Practice · Activity 5 of 7

    Viewer has two state variables. You click Details, then Next. What does the paragraph show?

    const words = ['alpha', 'beta', 'gamma'];
    
    function Viewer() {
      const [index, setIndex] = useState(0);
      const [showMore, setShowMore] = useState(false);
    
      return (
        <div>
          <button onClick={() => setIndex(index + 1)}>Next</button>
          <button onClick={() => setShowMore(!showMore)}>Details</button>
          <p>
            {words[index]}
            {showMore && ' (details on)'}
          </p>
        </div>
      );
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Each Likes button shows its own state and a module variable, shared. You click the first button twice, then the second once. What do the two buttons show?

    let shared = 0;
    
    function Likes() {
      const [own, setOwn] = useState(0);
    
      function handleClick() {
        shared = shared + 1;
        setOwn(own + 1);
      }
    
      return <button onClick={handleClick}>{own}/{shared}</button>;
    }
    
    function Page() {
      return (
        <>
          <Likes />
          <Likes />
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: flashcards. In main.jsx, write a Flashcards component over an array of cards, each with a question and an answer. It keeps two state variables: index, the current card, and showAnswer. It shows the question in an <h2>, then either the answer or a Show answer button, then a Next button. Next goes to the next card, back to the first after the last, and hides the answer again. Run it, show an answer, then click Next.

    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 colour gallery with two state variables

Gallery shows one colour from data.js at a time. index, the current colour, and showMore, whether the details are shown, are two state variables. Next moves index on, back to 0 after the last colour; Show details flips showMore. A run only shows the first render, so the output is Tomato with its details hidden. In the browser the page stays live: click Next and Show details to see the state change.

main.jsx

import { useState } from 'react';
import { colours } from './data.js';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const [showMore, setShowMore] = useState(false);

  function handleNextClick() {
    if (index < colours.length - 1) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  function handleMoreClick() {
    setShowMore(!showMore);
  }

  const colour = colours[index];
  return (
    <section>
      <button onClick={handleNextClick}>Next</button>
      <h2>{colour.name}</h2>
      <p>
        ({index + 1} of {colours.length})
      </p>
      <div className="swatch" style={{ background: colour.hex }} />
      <button onClick={handleMoreClick}>
        {showMore ? 'Hide' : 'Show'} details
      </button>
      {showMore && <p>{colour.details}</p>}
    </section>
  );
}

data.js

export const colours = [
  { name: 'Tomato', hex: '#ff6347', details: 'A warm red with a touch of orange.' },
  { name: 'Teal', hex: '#008080', details: 'A calm, deep blue-green.' },
  { name: 'Gold', hex: '#ffd700', details: 'A bright yellow with a warm glow.' }
];

styles.css

.swatch {
  width: 120px;
  height: 80px;
  border-radius: 8px;
  margin: 8px 0;
}

Run it with

npm run dev

Output

<!-- #root -->
<div id="root"><section><button>Next</button><h2>Tomato</h2><p>(1 of 3)</p><div class="swatch" style="background: rgb(255, 99, 71);"></div><button>Show details</button></section></div>
  • useState(0) and useState(false) give the initial values on the first render: the first colour, details hidden.
  • jsdom writes the colour #ff6347 as rgb(255, 99, 71); a browser does the same in the HTML it shows.
  • false renders nothing, so the details paragraph is missing until showMore is true.
  • After a click on Next, React renders Gallery again and useState(0) returns 1: React remembers the value, not the 0.
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

Give the gallery a memory

Page renders two galleries of colours. Run it and click Next: nothing changes, because Gallery keeps index in a local variable. Import useState from react and keep index in state instead, starting at 0, and change it with the setter in handleNextClick. Next on the last colour must go back to the first. The two galleries must keep separate indexes. Keep the export in front of Gallery.

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

    Add import { useState } from 'react'; at the top of main.jsx.

  2. Hint 2

    Replace let index = 0; with const [index, setIndex] = useState(0);.

  3. Hint 3

    In handleNextClick, write setIndex(index + 1) and setIndex(0) instead of assigning to index.

Show a solution

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

import { useState } from 'react';
import { colours } from './data.js';

export function Gallery() {
  const [index, setIndex] = useState(0);

  function handleNextClick() {
    if (index < colours.length - 1) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  const colour = colours[index];
  return (
    <section>
      <h2>{colour.name}</h2>
      <p>
        ({index + 1} of {colours.length})
      </p>
      <button onClick={handleNextClick}>Next</button>
    </section>
  );
}

export default function Page() {
  return (
    <>
      <Gallery />
      <Gallery />
    </>
  );
}
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 { colours } from './data.js';

export function Gallery() {
  // TODO: a local variable is not enough. Keep index in state.
  let index = 0;

  function handleNextClick() {
    if (index < colours.length - 1) {
      index = index + 1;
    } else {
      index = 0;
    }
  }

  const colour = colours[index];
  return (
    <section>
      <h2>{colour.name}</h2>
      <p>
        ({index + 1} of {colours.length})
      </p>
      <button onClick={handleNextClick}>Next</button>
    </section>
  );
}

export default function Page() {
  return (
    <>
      <Gallery />
      <Gallery />
    </>
  );
}

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 Page from './main.jsx';

const names = () => screen.getAllByRole('heading', {level: 2}).map((h) => h.textContent);
const nextButtons = () => screen.getAllByRole('button', {name: 'Next'});

test('Next shows the second colour', async () => {
  const user = userEvent.setup();
  render(<Page />);
  await user.click(nextButtons()[0]);
  assert.deepEqual(names(), ['Teal', 'Tomato'], 'after one click on the first Next the galleries show ' + JSON.stringify(names()));
});

test('Next on the last colour goes back to the first', async () => {
  const user = userEvent.setup();
  render(<Page />);
  for (let i = 0; i < 3; i++) await user.click(nextButtons()[0]);
  assert.equal(names()[0], 'Tomato', 'after three clicks the first gallery shows ' + JSON.stringify(names()[0]));
  const count = screen.getAllByText(/of 3/)[0].textContent;
  assert.equal(count, '(1 of 3)', 'after three clicks the first counter reads ' + JSON.stringify(count));
});

test('Each gallery keeps its own index', async () => {
  const user = userEvent.setup();
  render(<Page />);
  await user.click(nextButtons()[1]);
  await user.click(nextButtons()[0]);
  await user.click(nextButtons()[0]);
  assert.deepEqual(names(), ['Gold', 'Teal'], 'after two clicks on the first Next and one on the second the galleries show ' + JSON.stringify(names()));
});

data.js

export const colours = [
  { name: 'Tomato', hex: '#ff6347', details: 'A warm red with a touch of orange.' },
  { name: 'Teal', hex: '#008080', details: 'A calm, deep blue-green.' },
  { name: 'Gold', hex: '#ffd700', details: 'A bright yellow with a warm glow.' }
];

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

Add a Previous button

The gallery can only move forward. Add a Previous button before Next. A click on it shows the previous colour. On the first colour it is disabled, with disabled={index === 0}, so index never goes below 0. Write a handlePreviousClick that only lowers index when it is above 0. Next and Show details 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

    handlePreviousClick is like handleNextClick: if (index > 0) { setIndex(index - 1); }.

  2. Hint 2

    Put <button onClick={handlePreviousClick} disabled={index === 0}>Previous</button> before the Next button.

  3. Hint 3

    A disabled button ignores clicks, and the check in the handler keeps index at 0 or above anyway.

Show a solution

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

import { useState } from 'react';
import { colours } from './data.js';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const [showMore, setShowMore] = useState(false);

  function handleNextClick() {
    if (index < colours.length - 1) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  function handlePreviousClick() {
    if (index > 0) {
      setIndex(index - 1);
    }
  }

  function handleMoreClick() {
    setShowMore(!showMore);
  }

  const colour = colours[index];
  return (
    <section>
      <button onClick={handlePreviousClick} disabled={index === 0}>
        Previous
      </button>
      <button onClick={handleNextClick}>Next</button>
      <h2>{colour.name}</h2>
      <p>
        ({index + 1} of {colours.length})
      </p>
      <div className="swatch" style={{ background: colour.hex }} />
      <button onClick={handleMoreClick}>
        {showMore ? 'Hide' : 'Show'} details
      </button>
      {showMore && <p>{colour.details}</p>}
    </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';
import { colours } from './data.js';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const [showMore, setShowMore] = useState(false);

  function handleNextClick() {
    if (index < colours.length - 1) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  // TODO: add handlePreviousClick and a Previous button before Next.
  // Disable it on the first colour, so index never goes below 0.

  function handleMoreClick() {
    setShowMore(!showMore);
  }

  const colour = colours[index];
  return (
    <section>
      <button onClick={handleNextClick}>Next</button>
      <h2>{colour.name}</h2>
      <p>
        ({index + 1} of {colours.length})
      </p>
      <div className="swatch" style={{ background: colour.hex }} />
      <button onClick={handleMoreClick}>
        {showMore ? 'Hide' : 'Show'} details
      </button>
      {showMore && <p>{colour.details}</p>}
    </section>
  );
}

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 Gallery from './main.jsx';

const name = () => screen.getByRole('heading', {level: 2}).textContent;
const button = (label) => screen.getByRole('button', {name: label});

test('Previous is disabled on the first colour', () => {
  render(<Gallery />);
  assert.equal(button('Previous').disabled, true, 'Previous can be clicked on the first colour');
});

test('Next, then Previous, shows the first colour again', async () => {
  const user = userEvent.setup();
  render(<Gallery />);
  await user.click(button('Next'));
  assert.equal(button('Previous').disabled, false, 'Previous is still disabled on the second colour');
  await user.click(button('Previous'));
  assert.equal(name(), 'Tomato', 'after Next and Previous the gallery shows ' + JSON.stringify(name()));
});

test('Next twice, then Previous, shows Teal, 2 of 3', async () => {
  const user = userEvent.setup();
  render(<Gallery />);
  await user.click(button('Next'));
  await user.click(button('Next'));
  await user.click(button('Previous'));
  assert.equal(name(), 'Teal', 'the gallery shows ' + JSON.stringify(name()));
  const count = screen.getByText(/of 3/).textContent;
  assert.equal(count, '(2 of 3)', 'the counter reads ' + JSON.stringify(count));
});

test('Show details still works', async () => {
  const user = userEvent.setup();
  render(<Gallery />);
  await user.click(button('Show details'));
  assert.ok(screen.getByText('A warm red with a touch of orange.'), 'the details of Tomato are not shown');
});

data.js

export const colours = [
  { name: 'Tomato', hex: '#ff6347', details: 'A warm red with a touch of orange.' },
  { name: 'Teal', hex: '#008080', details: 'A calm, deep blue-green.' },
  { name: 'Gold', hex: '#ffd700', details: 'A bright yellow with a warm glow.' }
];

styles.css

.swatch {
  width: 120px;
  height: 80px;
  border-radius: 8px;
  margin: 8px 0;
}

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

Swapping the pair

import { useState } from 'react';

export default function Counter() {
  const [setCount, count] = useState(0);

  return <p>Count: {count}</p>;
}

What React prints

Functions are not valid as a React child.

Why, and the fix

useState always returns the value first and the setter second; the names do not change that. Here count holds the setter, a function, which React cannot show. Keep the order: const [count, setCount] = useState(0);.

Forgetting to call useState

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState;

  return <p>Count: {count}</p>;
}

What React prints

TypeError: useState is not iterable

Why, and the fix

Without parentheses, useState is the function itself, which cannot be destructured into a pair, and the run stops with Uncaught TypeError (Chrome and Node wording; Firefox and Safari word it differently). Call it with the initial value: useState(0).

Writing React.useState without importing React

export default function Counter() {
  const [count, setCount] = React.useState(0);

  return <p>Count: {count}</p>;
}

What React prints

ReferenceError: React is not defined

Why, and the fix

Older examples write React.useState. With the automatic JSX runtime nothing defines React for you, so the run stops with Uncaught ReferenceError. Import the Hook, import { useState } from 'react';, and call useState(0).

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

A local variable forgets

A gallery shows colours[index], and Next runs index = index + 1. Nothing happens on screen, for two reasons. Changing a local variable does not trigger a render: React does not know anything changed. And local variables do not persist between renders: when React renders the component again for any reason, let index = 0 runs again and starts over. A component needs two things to remember: data that is kept between renders, and a way to tell React to render again with the new data. useState gives both: a state variable that keeps the value, and a setter that stores a new value and triggers a render.

useState, its pair and its rules

const [index, setIndex] = useState(0) asks React to remember index. The argument is the initial value, used only on the first render. useState returns an array of exactly two items, the current value and its setter; the brackets are array destructuring. By convention the pair is named [something, setSomething]. A component can have as many state variables as it needs, such as index and showMore; React matches them by the order of the calls. useState is a Hook: a function starting with use, available only while React renders. Call Hooks at the top level of a component, never inside conditions, loops or nested functions.

Each instance has its own private state

State belongs to a component instance on the screen, not to the function or the file. Render <Gallery /> twice and you get two indexes: clicking Next in one leaves the other alone. A variable at the top of the module would be shared by both, and changing it would still not render either. State is also private: unlike props, the parent cannot read or change it, and Page does not even know whether Gallery has state. To keep two galleries in sync, you would move the state to their closest shared parent, as in lesson B1.4.

Sources

Last reviewed October 5, 2026