React · performance engineering

Automating React performance with agents and Legend State

Ali Aldhamenpublished September 23, 202610 min read

Here's a small course player. It has three lessons you can tick off, a progress bar, a captions toggle, and a box for notes. An agent built it for this article, and as far as a learner can tell, it works fine.

Before you type anything, make a guess. When you type one letter into the notes box, how many of the player's components re-render? Only one of them shows the note, so one seems like a fair answer.

Type a note in the player below and watch the counter.

Live demo · React state
The player, as the agent built it
Type a note and watch the component tree. Every lit node is a component that re-rendered.
Building with agents
Interactive course preview
Course progress0%
x
Off
0/ 8 components re‑rendered
Start typing in Your notes.
What updated
Hover a name to find it
  • CoursePlayer
  • LessonList
  • LessonIntro
  • LessonObservables
  • LessonConsumers
  • Progress
  • Notes
  • Captions
We're counting renders here, not milliseconds.

The counter says seven. Every letter you type re-renders seven of the player's eight components, and only the note editor, Notes, has anything new to show.

On a laptop you won't feel it. This player has three lessons and no video, though. In a real course player with fifty lessons, every keystroke would re-render all fifty lesson rows, and on a mid-range phone that turns into typing lag.

You can hunt these renders down by hand with the React Profiler, one screen at a time. That works for one screen, but it doesn't scale to every screen on every pull request. So in this article I hand the job to an agent. We'll set up a loop it can run on its own, then walk through it once on this player.

The plan
An optimization loop an agent can run
  1. Find
    Legend Doctor
    Flag code to move to Legend State and reads that are too wide.
    Chapter 03
  2. Fix
    Legend State
    Keep state in the parent. Read each field in the component that shows it.
    Chapter 04
  3. Verify
    agent-browser + genie-react
    Use the feature, then compare what re-rendered with what's on screen.
    Chapter 05
  4. Guard
    Legend Doctor in CI
    Flag new broad reads before they merge.
    Chapter 06
Every step has a tool the agent can run and check on its own. The loop repeats on every pull request.

Don't worry about the tool names yet. I'll introduce each one when we need it.

Why every component re-renders

The lesson rows are the strange part. You typed a note, and the rows don't show the note. Their props didn't change either, so why did they re-render?

The answer is Context. CoursePlayer, the top component, keeps all of the player's data in React state: which lessons are done, the note, and whether captions are on. It shares that data with every component below it through a Context. Props aren't the only thing React checks. When a Context value changes, React re-renders every component that reads that Context, even a component that only uses one field of it. Typing a note creates a new Context value, so each lesson row re-renders along with the editor.

So what if each lesson row read only its own checkbox, and the notes box read only the note? Then a new note would reach just the component that shows it.

Jay Meistrich argues for this in his App.js Conf 2026 talk, How to Build the Fastest Apps: Break the Rules⁠. His advice is to keep state at the top of the tree but read it at the bottom, in the components that show it. The top component passes down an object that never changes, and each component at the bottom of the tree, the leaf, subscribes to the one value it displays. He calls this "render once." In his own app, moving one update down to a single text component cut CPU use to a tenth. Jay also created Legend State, the library we'll use for the fix.

Checking every component for reads it doesn't need is slow, repetitive work. That makes it a good job for an agent.

Migrate to Legend State

To render once, each component needs a value it can subscribe to on its own. That value has to remember which components read it and update only those components when it changes. React state doesn't work that way.

Legend State⁠ is a React state library built around values like this, and it calls them observables. A component reads an observable with useValue, and React re-renders it only when the value it read changes. So a component that calls useValue(player$.note) re-renders when the note changes and at no other time.

I asked an agent to move the player's state to Legend State. It did, and the tests passed. If each component now reads only what it shows, typing should re-render one component. Type a note in the migrated player to check.

Live demo · Legend State, first try
Same player, now on Legend State
Type another note and count the lit nodes.
Building with agents
Interactive course preview
Course progress0%
x
Off
0/ 8 components re‑rendered
Start typing in Your notes.
What updated
Hover a name to find it
  • CoursePlayer
  • LessonList
  • LessonIntro
  • LessonObservables
  • LessonConsumers
  • Progress
  • Notes
  • Captions
We're counting renders here, not milliseconds.

The counter still says seven. Here's the top of the migrated player.

const player$ = useObservable(initialCourse());
const player = useValue(player$);
const note = player.note;

The problem is useValue(player$). It subscribes CoursePlayer to the whole course, so any change re-renders it. When the note changes, CoursePlayer passes the new note down through Context, and every component that reads that Context re-renders, the same as before. Tick off a lesson and CoursePlayer re-renders again, even though it never shows lesson completion. That click costs three renders: CoursePlayer, the lesson row, and the progress bar.

The agent swapped React state for observables but kept a read that covers the whole course. The code looks like normal Legend State, and it would be easy to approve in a code review. We need something that catches it automatically.

Let Legend Doctor guide the migration

My team at Genie builds Legend Doctor⁠ for this. It's a linter for Legend State. It flags React code that could move to Legend State and Legend State code that breaks its best practices, and each finding says what to change. An agent can make the edit and then check the result.

On this player, Doctor flagged one line, useValue(player$), because the parent subscribes to the whole course but only uses the note. It suggested narrowing the read to player$.note. The agent went a step further. With only the narrower read, the parent would still re-render on every keystroke and pass the note down through Context, so the agent moved the read into the note editor itself.

In the first tab below, click through The issue, Doctor's finding, and The agent's fix, and watch where useValue moves.

Recorded scan · Legend Doctor

From finding to fix

Start with the note editor from the player you just typed into. The other four tabs come from an extended version of the same player.

Live course-player source · render tracing omitted

The parent reads the whole course to pass down the note. Completing a lesson makes the parent render again. Typing a note also changes the shared value passed to the other components, so they render too.
function BroadLegendCourse({ children }: { children: ReactNode }) {
	const player$ = useObservable(initialCourse());
	const player = useValue(player$);
	const note = player.note;
	return (
		<CourseContext value={{ ...legendPlayer(player$), note: () => note }}>
			{children}
		</CourseContext>
	);
}
What changes for the learner?

Typing updates the note editor, not the whole player. Completing a lesson still moves progress from 0% to 33%, and only the lesson row and progress bar re-render.

Try the fixed player

In the fixed code, CoursePlayer doesn't call useValue at all. The note editor reads the note, and seven re-renders per keystroke drop to one.

The other four tabs come from an extended version of the player, with a button that exports the note, a browser tab title, a quiz, and lesson playback. Here's what Doctor flagged in each one and what the agent changed.

Doctor flagsThe agent moves it toImpact
useState for the noteuseObservableOnly the note editor re-renders while you type. The export button reads the note with peek(), which doesn't subscribe.
useEffect that sets the tab titleuseObserveEffectThe title updates without a component render.
useMemo for the quiz labelA computed observableThe result re-renders only when Passed changes to Try again, not on every score change.
Two separate writesbatchCode that watches the lesson never sees it finished but still playing.

One of these findings wasn't a straight recommendation. For the quiz label, Doctor asked for a source review, so the agent read the calculation and tested scores on both sides of the passing threshold before moving it. Every rule is listed in the detection catalog⁠.

Move the read to the leaf

After the fix, each component subscribes only to the field it shows, and the parent subscribes to nothing.

// In the note editor, not in the parent
const note = useValue(player$.note);

The note editor reads the note. Each lesson row reads its own completion, and the progress bar reads the completion of all three lessons. Type a note, then tick off a lesson, and watch which nodes light up in the component tree.

Live demo · Legend State, leaf reads
The note subscription, moved into the editor
Type a note, then tick off a lesson.
Building with agents
Interactive course preview
Course progress0%
x
Off
0/ 8 components re‑rendered
Start typing in Your notes.
What updated
Hover a name to find it
  • CoursePlayer
  • LessonList
  • LessonIntro
  • LessonObservables
  • LessonConsumers
  • Progress
  • Notes
  • Captions
We're counting renders here, not milliseconds.

Typing re-renders one component, the editor. Completing a lesson re-renders two, that lesson's row and the progress bar. Both of them show something that changed, so every render in this version does useful work.

Check it in a real browser

So far we've judged each version by its render count, and the count alone can hide a broken screen. Suppose the agent also removes the progress bar's subscription. A lesson click now re-renders one component instead of two, which looks like an improvement, and the progress bar stays at 0% forever.

So for every change, the agent checks two things: which components re-rendered, and what the learner sees. It uses two tools for this.

agent-browser⁠ is a browser an agent controls from the command line. The agent opens a page, clicks buttons, types into fields, and reads what's on the screen, the same way you've been using the player.

genie-react⁠ is the other one, and my team builds it too. It records every component render and effect in a running React app. A Vite plugin connects it to the app during development, and its command-line tool lets the agent read that record after each interaction.

Below is an illustrated replay of the agent running the same check against three versions of the player. Step through A broken fix and compare its report with the progress bar in the preview.

Replay · agent-browser + genie-react
Complete one lesson, three ways
The agent clicks the same button in three versions of the player, then checks two things: which components re-rendered, and whether the screen still shows the right answer.
Before the fix · Step 1 of 5
Start with the same lesson and the same starting data.
agent-browserCourse preview
Building with agentsLesson 1 of 3
Observable state
Leaf consumers
Course progress0%
Your notes
Read state where it is used.
Captions
Off
Expected: the first lesson completes and progress reaches 33%. Notes and captions stay the same.
genie-reactIllustrated render report
Components updated· / 8
Select a row to locate its component in the preview.
The agent's decision
Waiting for the click and its render report. The agent also checks that progress shows 33%.

A broken fix has the lowest render count of the three, and the agent still rejects it. The render count alone looks like an improvement. The agent rejects it because the preview shows progress stuck at 0%, not the 33% it expected.

Run the loop on every change

If you want to try this, start with one feature and one interaction, like this player. Forms, navigation, and server-data caching can wait for later runs. Then give the agent a goal with acceptance criteria. This is the one I used.

/goal Acceptance criteria:
- Maximum performance
- Minimum re-renders
- A full genie-react report
- Everything validated with Legend Doctor and agent-browser
- No shortcuts. No caveats.
Making the before-and-after comparison trustworthy

Confirm that genie-react is connected and can see your application's components. Record the interaction before editing. Restore the same starting data, then record the interaction again after editing.

Repeat the comparison on the same device. Follow the genie-react performance measurement workflow⁠ for repeated measurements. Before drawing a conclusion, check whether the report missed any components or listed warnings.

If the interaction is still slow after you cut the extra renders, look at network requests, expensive calculations, and large lists.

A later feature can bring the same broad read back, so we run Legend Doctor in CI on every pull request. At first it only reports findings and doesn't block merges.

pnpm exec legend-doctor src/features/course-player

Once your team trusts the rules, add --fail-on change to block merges on the findings you care about. The GitHub Action⁠ can also comment new findings on pull requests, and the agent can fix those on its next run. Keep the interaction tests as well, because a code scan can't tell you whether the progress bar still works.

Here are the render counts from each version of the player.

  1. 7The agent's first buildper keystroke, only Notes shows the note
  2. 7The first Legend State migrationper keystroke, the parent still reads the whole course
  3. 1Reads moved to the leafper keystroke, only the editor
  4. 1The fix that broke progressper lesson click, down from 2, with progress stuck at 0%
Components re-rendered in each version of the player, for the interaction named under each number.

We started at seven re-renders for one letter and ended at one, with the progress bar still working. We counted renders, not milliseconds. A slow network request or a long list needs its own measurement.

If you try this on your own app, start with one interaction that feels slow and count what re-renders. Thanks for reading.