Skip to content
aviral gupta

// B2.6 · ~40 min · Beginner

Purity and the UI tree

In this build you keep components pure, see what Strict Mode reveals, read your app as two trees, and build a profile gallery across three files.

Lesson 6 of 6 in B2 Describing the UI

End of the module

You will be able to

  • Write pure components that change nothing outside and give the same JSX for the same props
  • Explain why Strict Mode doubles impure output, and keep side effects in event handlers
  • Read the render tree and the module dependency tree of a multi-file gallery
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B2.5: filter, map and keys. What HTML does Todo put into #root?

    const tasks = [
      { id: 1, title: 'Shop', done: true },
      { id: 2, title: 'Cook', done: false }
    ];
    
    function Todo() {
      return (
        <ul>
          {tasks.filter(t => !t.done).map(t => <li key={t.id}>{t.title}</li>)}
        </ul>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. The course runner, like Vite's template, renders inside <StrictMode>. Which numbers do the three tickets show?

    let ticket = 0;
    
    function Ticket() {
      ticket = ticket + 1;
      return <li>Ticket #{ticket}</li>;
    }
    
    function Queue() {
      return (
        <ul>
          <Ticket />
          <Ticket />
          <Ticket />
        </ul>
      );
    }
  3. Practice · Activity 3 of 7

    The parent now passes number={1}, number={2} and number={3}. Fill in the prop that makes Ticket pure.

    function Ticket({ ____ }) {
      return <li>Ticket #{number}</li>;
    }
    function Ticket({ }) {
  4. Practice · Activity 4 of 7

    Match each piece of code to what it is.

  5. Practice · Activity 5 of 7

    Lowest shows the smallest score. What does the array scores hold after it has rendered?

    const scores = [3, 1, 2];
    
    function Lowest() {
      const sorted = scores.sort();
      return <p>Lowest: {sorted[0]}</p>;
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Stars pushes into an array while it renders, and Strict Mode calls it twice. What text does Rating show?

    function Stars({ count }) {
      const row = [];
      for (let i = 0; i < count; i++) {
        row.push('★');
      }
      return <p>{row.join('')}</p>;
    }
    
    function Rating() {
      return <Stars count={3} />;
    }
  7. Apply · Activity 7 of 7

    Mini-task: orders in an event handler. In main.jsx, write an Order component with a drink prop and a cups state. Its button shows the drink and how many cups were ordered. Clicking it adds one cup and prints "ordered Tea" (or the drink) with console.log. Put both changes in the click handler, none in the component body. Render two Orders. Run it and click once: the line prints once, although Strict Mode renders Order twice.

    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

Pure and impure in Strict Mode

App renders two Impure components, two Pure ones and Seats. Impure adds 1 to visits, a variable outside it, every time it is called. Pure only reads its prop. Seats, in its own file, pushes into an array it has just created. The runner renders inside <StrictMode>, which calls every component twice. Render tree: App is the root, the other components are its leaves. Module dependency tree: main.jsx imports Seats.jsx.

main.jsx

import Seats from './Seats.jsx';

let visits = 0;

function Impure() {
  visits = visits + 1;
  return <p>Impure: visit #{visits}</p>;
}

function Pure({ visit }) {
  return <p>Pure: visit #{visit}</p>;
}

export default function App() {
  return (
    <section>
      <Impure />
      <Impure />
      <Pure visit={1} />
      <Pure visit={2} />
      <Seats count={3} />
    </section>
  );
}

Seats.jsx

export default function Seats({ count }) {
  const seats = [];
  for (let i = 1; i <= count; i++) {
    seats.push(<li key={i}>Seat {i}</li>);
  }
  return <ul>{seats}</ul>;
}

Run it with

npm run dev

Output

<!-- #root -->
<div id="root"><section><p>Impure: visit #2</p><p>Impure: visit #4</p><p>Pure: visit #1</p><p>Pure: visit #2</p><ul><li>Seat 1</li><li>Seat 2</li><li>Seat 3</li></ul></section></div>
  • Impure was called four times, twice per component, so its two copies show #2 and #4.
  • Pure shows exactly its prop, however often it is called.
  • Seats builds three seats on each call, because its array is new every time: local mutation.
  • In production Strict Mode does nothing, and Impure would show #1 and #2: the bug would hide.
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

A running total without an outside variable

Each Cup should show its size and how much has been poured so far: 200, then 450, then 750 ml. Run it: Strict Mode makes the totals wrong, because Cup adds to poured, a variable outside it. Make Cup pure: it gets a second prop, soFar, and shows only its props. Work out the running total in TeaRound with a local variable while you map the cups. Keep the names Cup and TeaRound and their exports.

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

    Cup becomes function Cup({ size, soFar }) and renders {soFar} instead of poured.

  2. Hint 2

    In TeaRound, declare let poured = 0; before the map. It is created during this render, so changing it is local mutation.

  3. Hint 3

    Use a block body in the map: poured = poured + cup.size; then return <Cup key={cup.id} size={cup.size} soFar={poured} />;.

Show a solution

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

const cups = [
  { id: 'c1', size: 200 },
  { id: 'c2', size: 250 },
  { id: 'c3', size: 300 }
];

export function Cup({ size, soFar }) {
  return <li>{size} ml, {soFar} ml so far</li>;
}

export default function TeaRound() {
  let poured = 0;
  const items = cups.map(cup => {
    poured = poured + cup.size;
    return <Cup key={cup.id} size={cup.size} soFar={poured} />;
  });
  return <ul>{items}</ul>;
}
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

const cups = [
  { id: 'c1', size: 200 },
  { id: 'c2', size: 250 },
  { id: 'c3', size: 300 }
];

let poured = 0;

// TODO: make Cup pure: it may only use its props.
// Work out the running total in TeaRound instead.
export function Cup({ size }) {
  poured = poured + size;
  return <li>{size} ml, {poured} ml so far</li>;
}

export default function TeaRound() {
  return (
    <ul>
      {cups.map(cup => <Cup key={cup.id} size={cup.size} />)}
    </ul>
  );
}

main.test.jsx

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

test('The cups show running totals of 200, 450 and 750 ml', () => {
  render(<TeaRound />);
  const items = screen.getAllByRole('listitem').map((li) => li.textContent);
  assert.deepEqual(items, ['200 ml, 200 ml so far', '250 ml, 450 ml so far', '300 ml, 750 ml so far'], 'the list reads ' + JSON.stringify(items));
});

test('Cup shows what its props say', () => {
  render(<ul><Cup size={100} soFar={300} /></ul>);
  const item = screen.getByRole('listitem').textContent;
  assert.equal(item, '100 ml, 300 ml so far', 'the cup reads ' + JSON.stringify(item));
});

test('Rendering TeaRound twice gives the same HTML', () => {
  const first = render(<TeaRound />).container.innerHTML;
  const second = render(<TeaRound />).container.innerHTML;
  assert.equal(second, first, 'the second render differs from the first');
});

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

Build: a profile gallery

The gallery is spread over three files: data.js holds the people, Profile.jsx the Profile and Avatar components, main.jsx the Gallery. Gallery is impure: it sorts the imported array in place, numbers the profiles with a variable outside the component, and gives no keys. Rewrite Gallery: sort a copy by name, number the profiles 1 to 4 from the map index, and give each Profile key={person.id}. Rendering twice must give the same HTML, and data.js must stay unchanged.

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

    [...people] makes a new array, which you may sort: const sorted = [...people].sort((a, b) => a.name.localeCompare(b.name));.

  2. Hint 2

    map() passes the index as its second argument: sorted.map((person, index) => …), and position={index + 1}.

  3. Hint 3

    Delete let position = 0; and use <Profile key={person.id} person={person} position={index + 1} />.

Show a solution

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

import Profile from './Profile.jsx';
import { people } from './data.js';

export default function Gallery() {
  const sorted = [...people].sort((a, b) => a.name.localeCompare(b.name));
  return (
    <section>
      <h1>Scientist gallery</h1>
      <p>{sorted.length} profiles</p>
      {sorted.map((person, index) => (
        <Profile key={person.id} person={person} position={index + 1} />
      ))}
    </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 Profile from './Profile.jsx';
import { people } from './data.js';

let position = 0;

// TODO: sort by name without changing people, number the profiles
// from 1 without the outside variable, and give each Profile a key.
export default function Gallery() {
  people.sort((a, b) => a.name.localeCompare(b.name));
  return (
    <section>
      <h1>Scientist gallery</h1>
      <p>{people.length} profiles</p>
      {people.map(person => {
        position = position + 1;
        return <Profile person={person} position={position} />;
      })}
    </section>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import Gallery from './main.jsx';
import {people} from './data.js';

test('React prints no warnings', () => {
  const warnings = [];
  const original = console.error;
  console.error = (...args) => warnings.push(args.join(' '));
  try {
    render(<Gallery />);
  } finally {
    console.error = original;
  }
  assert.deepEqual(warnings, [], 'console.error while rendering');
});

test('Four profiles, sorted by name and numbered from 1', () => {
  render(<Gallery />);
  const names = screen.getAllByRole('heading', {level: 2}).map((h) => h.textContent);
  assert.deepEqual(names, ['1. Alan Turing', '2. C. V. Raman', '3. Marie Skłodowska-Curie', '4. Rosalind Franklin'], 'the profiles read ' + JSON.stringify(names));
  assert.equal(screen.getByText(/profiles$/).textContent, '4 profiles', 'the count line');
});

test('Each profile shows an avatar named after the person', () => {
  render(<Gallery />);
  const avatars = screen.getAllByRole('img').map((img) => img.getAttribute('aria-label'));
  assert.equal(avatars.length, 4, 'avatars: ' + avatars.length);
  assert.ok(avatars.includes('Alan Turing'), 'no avatar is named Alan Turing');
});

test('Rendering does not change the array in data.js', () => {
  render(<Gallery />);
  const ids = people.map((person) => person.id);
  assert.deepEqual(ids, ['msc', 'atu', 'rfr', 'cvr'], 'data.js now holds ' + JSON.stringify(ids));
});

test('Rendering twice gives the same HTML', () => {
  const first = render(<Gallery />).container.innerHTML;
  const second = render(<Gallery />).container.innerHTML;
  assert.equal(second, first, 'the second render differs from the first');
});

Profile.jsx

export function Avatar({ person }) {
  const initials = person.name
    .split(' ')
    .map(part => part[0])
    .join('');
  return (
    <span className="avatar" role="img" aria-label={person.name}>
      {initials}
    </span>
  );
}

export default function Profile({ person, position }) {
  return (
    <article className="profile">
      <h2>
        {position}. {person.name}
      </h2>
      <Avatar person={person} />
      <p>
        {person.profession}, known for {person.discovery}
      </p>
    </article>
  );
}

data.js

export const people = [
  { id: 'msc', name: 'Marie Skłodowska-Curie', profession: 'physicist and chemist', discovery: 'polonium and radium' },
  { id: 'atu', name: 'Alan Turing', profession: 'mathematician', discovery: 'the Turing machine' },
  { id: 'rfr', name: 'Rosalind Franklin', profession: 'chemist', discovery: 'X-ray images of DNA' },
  { id: 'cvr', name: 'C. V. Raman', profession: 'physicist', discovery: 'Raman scattering' }
];

styles.css

.profile {
  border: 1px solid #ccc;
  border-radius: 8px;
  margin: 8px 0;
  padding: 8px 12px;
}

.avatar {
  display: inline-block;
  width: 48px;
  height: 48px;
  line-height: 48px;
  border-radius: 50%;
  background: #446;
  color: white;
  text-align: center;
}

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

Changing the DOM while rendering

export default function Clock() {
  const hour = 3;
  if (hour < 6) {
    document.getElementById('time').className = 'night';
  } else {
    document.getElementById('time').className = 'day';
  }
  return <h1 id="time">{hour}:00</h1>;
}

What React prints

TypeError: Cannot set properties of null (setting 'className')

Why, and the fix

While Clock renders, its <h1> is not on the page yet, so getElementById returns null, and the run prints Uncaught TypeError (Chrome and Node wording; Firefox and Safari word it differently). Changing the DOM is a side effect anyway. Compute the class and return it in the JSX: <h1 id="time" className={hour < 6 ? 'night' : 'day'}>.

Adding to the children a component received

function Menu({ children }) {
  children.push(<li key="help">Help</li>);
  return <ul>{children}</ul>;
}

export default function App() {
  return (
    <Menu>
      <li>Home</li>
      <li>Blog</li>
    </Menu>
  );
}

What React prints

TypeError: Cannot add property 2, object is not extensible

Why, and the fix

children is a prop, and props are inputs that a component must not change. In development React freezes the children array, so push throws (Chrome and Node wording). Add the item in the JSX instead: return <ul>{children}<li>Help</li></ul>;.

An import cycle in the module tree

import Profile from './Profile.jsx';

export const SIZE = 64;

export default function App() {
  return <Profile name="Ada Lovelace" />;
}

What React prints

ReferenceError: Cannot access 'SIZE' before initialization

Why, and the fix

main.jsx imports Profile.jsx, and Profile.jsx imports main.jsx: a cycle in the module dependency tree. JavaScript runs Profile.jsx first, before main.jsx has defined SIZE (Chrome and Node wording; Firefox and Safari word it differently). Keep the dependency tree one-way: move SIZE into its own module, such as sizes.js, and import it from both files.

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 component is a formula

react.dev: React assumes that every component you write is a pure function. Pure means two things. It minds its own business: it does not change variables or objects that existed before it was called, such as a counter outside the component, an imported array or its props. And the same inputs give the same output: Recipe with drinkers={2} always returns the same JSX. Changing what you have just created is fine: an array made inside the component may be pushed to while rendering. react.dev calls this local mutation. Sorting an imported array in place is not local: copy it first, [...people].sort(…).

Strict Mode renders twice to catch impurity

In development, Strict Mode calls each component function twice. Vite's template and this course's runner turn it on, so every run here shows it. A pure component does not notice: it computes the same JSX twice. An impure one does: if Ticket adds 1 to an outside counter, three Tickets show #2, #4 and #6 instead of #1, #2 and #3. Strict Mode has no effect in production. Changes that must happen, side effects, belong in event handlers: React runs them when you click, not while rendering, and only once per click. useEffect is the last resort.

Your app as two trees

The render tree has a node for each component: the root is the root component, here Gallery, and each arrow points from parent to child. HTML tags are not part of it. Top-level components sit near the root; leaf components have no child components and often re-render. With conditional rendering the tree may differ per render. The module dependency tree has a node for each module, including data files such as data.js, and a branch for each import. main.jsx is its root. Bundlers use it to decide what code to ship.

Sources

Last reviewed October 5, 2026