js-dojo

1 count your renders

GOAL

predict the exact number of times each component function runs after a mount and after a parent setState, then check the guess against a redraw tally.

CONCEPT

a render is one call of your component function by React. React re-runs a component when its own state changes, and it re-runs every child of a re-rendering parent, even a child whose props did not change at all.

HINT

count function calls, not DOM updates. The two skills below take no props whatsoever, yet a click that only moves the parent's own state still reaches both of them.

MIRRORS

the skill tree panel on the HUD. Spending a single point redraws the panel, and every branch skill and leaf skill hanging under it redraws with it, including the ones nothing touched. That is why a real skill tree wraps its nodes in memo().

Run

This koan renders React, so it needs a one-time setup.

pnpm koan 08-render-detection/01-count-your-renders.tsx

Source

// DOJO · Module 8 / Exercise 1 — count your renders
// GOAL: predict the exact number of times each component function runs
//       after a mount and after a parent setState, then check the guess
//       against a redraw tally.
// CONCEPT: a render is one call of your component function by React.
//       React re-runs a component when its own state changes, and it
//       re-runs every child of a re-rendering parent, even a child
//       whose props did not change at all.
// HINT: count function calls, not DOM updates. The two skills below
//       take no props whatsoever, yet a click that only moves the
//       parent's own state still reaches both of them.
// MIRRORS: the skill tree panel on the HUD. Spending a single point
//       redraws the panel, and every branch skill and leaf skill
//       hanging under it redraws with it, including the ones nothing
//       touched.
//       That is why a real skill tree wraps its nodes in memo().
// Run: pnpm koan 08-render-detection/01-count-your-renders.tsx

import { useState } from 'react';
import { afterEach, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';

// Redraw tally: each component bumps its own name as its body starts.
const redraws = new Map<string, number>();

function bump(name: string): void {
  redraws.set(name, (redraws.get(name) ?? 0) + 1);
}

function count(name: string): number {
  return redraws.get(name) ?? 0;
}

afterEach(() => {
  cleanup();
  redraws.clear();
});

function BranchSkill() {
  bump('BranchSkill');
  return <div>branch skill</div>;
}

function LeafSkill() {
  bump('LeafSkill');
  return <div>leaf skill</div>;
}

function SkillTree() {
  bump('SkillTree');
  const [spent, setSpent] = useState(0);
  return (
    <div>
      <button onClick={() => setSpent(spent + 1)}>
        {`points spent: ${spent}`}
      </button>
      <BranchSkill />
      <LeafSkill />
    </div>
  );
}

type Tally = {
  SkillTree: number | null;
  BranchSkill: number | null;
  LeafSkill: number | null;
};

function tally(): Tally {
  return {
    SkillTree: count('SkillTree'),
    BranchSkill: count('BranchSkill'),
    LeafSkill: count('LeafSkill'),
  };
}

// --- TODO 1 ---------------------------------------------------------
// The first render of a component is its mount. render(<SkillTree />)
// mounts SkillTree, and SkillTree's JSX mounts BranchSkill and
// LeafSkill. Replace each null with the number of times that component
// function ran.
const AFTER_MOUNT: Tally = {
  SkillTree: null,
  BranchSkill: null,
  LeafSkill: null,
};

// --- TODO 2 ---------------------------------------------------------
// The button calls setSpent, so only SkillTree's own state changes.
// BranchSkill and LeafSkill take no props, so nothing about them is
// different - apply the rule from the header anyway. Predict each total
// tally after the button has been clicked TWICE (the mount render is
// included in the totals).
const AFTER_TWO_CLICKS: Tally = {
  SkillTree: null,
  BranchSkill: null,
  LeafSkill: null,
};

it('TODO 1 — a mount runs every component function once', () => {
  render(<SkillTree />);
  expect(tally(), 'TODO 1 — tally after the mount').toEqual(AFTER_MOUNT);
});

it('TODO 2 — a parent setState re-runs the children too', () => {
  render(<SkillTree />);
  const button = screen.getByRole('button');
  fireEvent.click(button);
  fireEvent.click(button);
  expect(tally(), 'TODO 2 — tally after two clicks').toEqual(
    AFTER_TWO_CLICKS,
  );
});

Solution