Skip to content

React and TypeScript, 8 of 26

Same list, twice.
Watch the counter.

A client asks you to measure before and after with evidence rather than vibes. Here is the smallest honest version: one list built the way it usually gets built, one built properly, and the same measurement taken on both.

There are two boxes on purpose. The note field changes nothing the list depends on, and the filter changes which rows exist. Memo wins the first outright and only helps with the second, and a demonstration that showed you the first number alone would be flattering.

-rows rendered
-keystroke to finished DOM
-rows rendered
-keystroke to finished DOM

Nothing picked yet. Clicking a row is what the callback is for. 4000 rows match.

400 of the matches are in the scroll box, which is a limit on the list and not on the measurement. The filter still runs over all 4000.

Five decisions

01

memo on its own usually does nothing

It compares props and skips the render when they match. The handler passed to every row is a fresh arrow function on each parent render, so the props never match and all four thousand rows render anyway. That pairing is the reason people conclude memo is useless, and the fix is the callback, not the memo.

02

React’s own Profiler reports nothing here

It is the obvious tool and it is compiled out of a production build, so on a deployed page it returns zero for everything. The timer instead starts on the keystroke and stops in a layout effect, which runs after the DOM has changed and before the browser paints.

03

The render counter lives outside React

Counting renders in state causes renders, which is the observer changing the thing it observes. It sits in a plain object, and the effect that reads it clears its own trigger first, so the measurement does not measure itself.

04

The second box is the one that keeps it honest

On an unrelated keystroke the memo list renders nothing at all, which is the number everybody quotes. On a filter keystroke it still renders every row that was not there a moment ago, because no amount of memo can skip a row that has just appeared. Both numbers are on the page.

05

Four thousand rows, four hundred drawn

The scroll box holds four hundred, and the filter still runs over all four thousand. Windowing the list would fix the symptom here and hide the lesson, which is what the extra renders cost once a list stops being small.

Five things that must not compile

Anybody can write “TypeScript catches this” under a snippet. Whether it does depends on the exact type, the strictness flags and the compiler version, and the sentence stays on the page long after one of the three has changed.

So each of these is compiled on its own by a script, under this project's own settings, and what the compiler said is recorded. A fixture that compiles fails the script. Last run with Version 5.9.3.

01-read-before-checking.ts

Reading the value before checking whether there is one.

import type { Result } from '../lib/result';

declare const r: Result<{ name: string }>;

export const name = r.value.name;
01-read-before-checking.ts(10,23): error TS2339: Property 'value' does not exist on type 'Result<{ name: string; }>'.
  Property 'value' does not exist on type '{ ok: false; error: string; }'.

02-both-at-once.ts

A success carrying an error message.

import type { Result } from '../lib/result';

export const r: Result<number> = { ok: true, value: 1, error: 'but also this' };
02-both-at-once.ts(9,56): error TS2353: Object literal may only specify known properties, and 'error' does not exist in type '{ ok: true; value: number; }'.

03-a-failure-with-no-reason.ts

A failure with nothing to say.

import type { Result } from '../lib/result';

export const r: Result<number> = { ok: false };
03-a-failure-with-no-reason.ts(9,14): error TS2322: Type '{ ok: false; }' is not assignable to type 'Result<number>'.
  Property 'error' is missing in type '{ ok: false; }' but required in type '{ ok: false; error: string; }'.

04-a-field-that-state-does-not-have.ts

Reading a field that only exists in another state.

import type { Job } from '../lib/result';

declare const job: Job;

export const took = job.state === 'queued' ? job.finishedAt : 0;
04-a-field-that-state-does-not-have.ts(10,50): error TS2339: Property 'finishedAt' does not exist on type '{ state: "queued"; id: string; }'.

05-a-state-nobody-handled.ts

A new state added to the union, and a switch that never heard about it.

import type { Job } from '../lib/result';

type JobWithCancel = Job | { state: 'cancelled'; id: string; by: string };

export function describe(job: JobWithCancel): string {
  switch (job.state) {
    case 'queued':
      return 'waiting';
    case 'running':
      return 'running';
    case 'done':
      return 'done';
    case 'failed':
      return 'failed';
    default: {
      const unreachable: never = job;
      return unreachable;
    }
  }
}
05-a-state-nobody-handled.ts(22,13): error TS2322: Type '{ state: "cancelled"; id: string; by: string; }' is not assignable to type 'never'.

What this does not prove

A four thousand row list is a demonstration, not an application. Real slowness usually comes from a context that everything subscribes to, or from a component tree rebuilt on every route change, and neither of those fits on one page.

What it does show is the habit: change one thing, measure the same way before and after, and put the number where somebody else can check it.

The rest of this site is the same stack. The audit tool is the largest interactive piece of it. The whole list is 41 requirements from 114 job posts, with the gaps at the same size as the wins.