
# React performance lessons from 517 examples

By Danylo Pravda, 2026-10-08

Beamline's speed bench renders every example of every component, 190 parts and 517 examples, in a frame-exact
rig at 120 Hz, and counts what React and the browser did: commits, component renders, hooks, DOM changes, style and
layout passes. Counts are exact and repeat run to run; milliseconds are reported too, but they move with the machine.
On its first night it found the problems below. Each one is a pattern that shows up in most React component libraries,
so the numbers are worth more than the story.

## How it measures

- **A virtual clock.** The browser draws a frame only when the rig asks for one, every 8.3 ms of virtual time, so a
  slow machine produces the same frames as a fast one, just later. That is what makes counts exact.
- **Counts gate, time informs.** Every example has recorded ceilings (hooks exact, the other counts within a couple of
  per cent), and a change that goes over one fails the check unless the ceiling is raised with a reason. Time is
  compared with paired runs and an interval, never two medians: on a busy machine, unpaired medians once called a
  deliberately slowed build 21 % faster.
- **Looks and motion are guarded.** Every comparison also checks that pixels and motion did not change, so a speed fix
  cannot quietly drop an animation.

## 1. Every instance reading the DOM for itself

**Found:** across all 517 examples, Beamline's shared hooks took 22.8 % of all JavaScript time, close to React itself
(26 %) and far above every component file together (8.7 %). One hook, which reads each part's resolved appearance,
was 7.8 % on its own, in 506 of 517 examples. Each instance read computed styles in a layout effect, so each one forced
its own style pass, and stored the result in state, so every Button rendered twice on mount.

**Fix:** read once per commit for everyone. No host element in state, a starting value from the last read in the same
scope, every consumer's read batched after the commit, and one synchronous render only for those whose values changed.

**Measured:** Button examples went from 7 commits to 4 and from 209 component renders to 88 (−58 %); the data grid
from 1,499 renders to 1,202 (−20 %); every chart, select, combobox and tab set lost one to three commits. A Next.js
check of the same build showed no hydration warning.

**The general lesson:** `getComputedStyle` or `offsetWidth` in a layout effect is cheap once and expensive times a
hundred. Batch the reads for all instances, then write.

## 2. An effect with no dependency list

**Found:** the chart frame's label measurer had a layout effect with no dependency array, so it re-read computed styles
and re-subscribed to font loading on every render, and charts render on every frame of their entry animation.

**Fix:** read once per look, not once per render.

**Measured:** a chart's entry went from 129 commits to 66; the line chart from 154 to 78; the analytics dashboard from
132 to 70.

**The general lesson:** a missing dependency array is invisible in review and multiplies with every render you did not
think about. Animations are where they hurt.

## 3. One component per cell

**Found:** the activity heatmap's loading state, one placeholder component per cell, was the heaviest mount of all 517
examples: 2,677 components and 14,019 hooks.

**Fix:** one `SkeletonGroup` for many placeholder shapes, under one visibility and motion check.

**Measured:** 2,677 renders became 18, 14,019 hooks became 69, and the mount's CPU time fell from 1,172 ms to 444 ms.

## 4. The theme in React state

**Found:** switching the theme on the data grid's page re-rendered thousands of components, though CSS already applies
the change: the theme lived in React state at the top of the page.

**Fix:** the appearance scope now has narrow channels (defaults, motion, density, elements, changes), so a part
re-renders only for what it reads, and the page keeps its elements while only the appearance changes.

**Measured:** one switch went from 2,871 renders in 4 commits to 307 in 2, and a page switched to light is
pixel-identical to the same page loaded in light.

## 5. Totals recomputed on every render

**Found:** scrolling the data grid through 5,000 rows cost about 90 ms of main-thread time per 400 px step, against a
frame budget of 8.3 ms. A trace put half of the grid's own time in the footer totals: count, unique, sum and average
over all 5,000 rows, recomputed in every render, three renders per step.

**Fix:** memoise the totals on the row set.

**Measured:** scrolling used 18.7 % less main-thread CPU (95 % interval −39.0 % to −8.6 %), with sort, search and
select-all unchanged and no pixel or motion change. It cost 33 bytes gzipped and one hook per grid, both recorded as
the reason the ceilings moved.

## 6. Comparing text the long way

**Found:** a diff of a 400-line file took 72.7 million instructions.

**Fix:** compare lines as integers and skip the lines both texts start with; the same walk, the same result (3,000
random pairs gave output identical to the old code).

**Measured:** 22.1 million instructions per diff (−70 %).

## What was already fine

- **Off screen, almost everything stops:** 475 of 515 examples do no React or DOM work once scrolled away; most of
  the other 40 are live demos (feeds, streams) that are meant to keep moving.
- **Number formatting is not worth optimising today:** formatting a market number costs about 7,000 instructions, and
  with about 150 numbers per order book update that is roughly 0.3 ms.
- **No document-wide `:has()`:** every one of the 12 `:has()` selectors in the built CSS is anchored to a component's
  own class, so none of them makes every DOM change slower.

## What it means if you use Beamline

These fixes shipped in the releases of 8 October 2026 ([changelog](https://beamline.io/changelog)), and the bench now runs before a change
is accepted: if a part starts rendering more, mounting more hooks or changing how it looks or moves, the check fails
before it reaches a release. The parts above are live in the [component library](https://beamline.io/components/), and the rules every
part follows are in [Conventions](https://beamline.io/docs/conventions).
