Skip to content

perf(react): re-render only elements whose state changed - #392

Open
shteynu wants to merge 1 commit into
vercel-labs:mainfrom
shteynu:perf/react-state-subscriptions
Open

shteynu wants to merge 1 commit into
vercel-labs:mainfrom
shteynu:perf/react-state-subscriptions

Conversation

@shteynu

@shteynu shteynu commented Oct 8, 2026

Copy link
Copy Markdown

Summary

#325 made spec changes cheap: an unchanged element no longer re-runs when a new spec arrives. State changes still re-run everything. Every ElementRenderer reads the whole state through StateContext and VisibilityContext, and a context change bypasses React.memo, so any set re-resolves props and visibility for every element in the spec, including writes to paths no element reads. CatalogComponentBoundary then stops most catalog components from rendering, but the per-element work stays O(N) per write, for example on every keystroke in a bound input.

This change:

  • adds a stable useStateSubscription() to @internal/react-state (subscribe / getSnapshot / getServerSnapshot) next to the existing per-update StateContext
  • collects the state paths each element's expressions read: $state, $bindState, $bindItem (against the repeat base path), $template references, visibility conditions and watch keys
  • subscribes each element through useSyncExternalStore with a selector over those paths, so an element re-renders only when a value it reads changes (compared with Object.is, which relies on the store's structural sharing)
  • keeps elements that use a custom directive subscribed to the whole state model, since a directive's resolve can read anything from ctx.stateModel
  • subscribes RepeatChildren to its statePath plus the state paths of its item filter
  • memoizes the RepeatScopeProvider value, so editing one item no longer re-runs the elements of every other item

Public spec, renderer, state and binding contracts are unchanged. useStateSubscription is internal and not exported from @json-render/react.

Evidence

Element bodies are ElementRenderer executions, counted through registry lookups. Components are catalog component renders. The tree has 500 cards, each with one text, plus a repeat list, a visible gate and a $template (1,005 spec elements). The same commit (fc2a696) was built with and without this change:

Scenario before: bodies / components after: bodies / components
Spec patch to one leaf 3 / 3 3 / 3
State write read by one leaf 1,013 / 1 1 / 1
State write read by nobody 1,013 / 0 0 / 0
visible gate, $template, replacing a parent object 1,014 / 1 1 / 1
One repeat item's field 1,014 / 1 1 / 1
300 writes to one path, per write 13.1 ms 0.65 ms

Timings are from a React development build in jsdom (production builds do not expose act). Read them as a ratio, not as absolute numbers. On a 105-element tree the same per-write timing goes from 1.67 ms to 0.27 ms. The DOM was checked after every step in both builds.

state-subscriptions.test.tsx covers:

  • re-running only the element that reads a written path
  • an element whose spec change points it at a path that changed while it was not subscribed
  • visible, $template and $cond, plus replacing a parent object of a read path
  • $bindItem in a repeat, where only the edited item re-runs and appended items render
  • custom directives reading the whole state model

The two isolation tests fail on main; the other three pass on both.

Checked locally:

  • vitest run over packages/react, react-state, react-pdf, react-native and ink (the packages that use @internal/react-state): 153 passed
  • tsc --noEmit in packages/react: no new errors. The 23 existing errors in test files are unchanged.
  • tsup builds and prettier pass

Not in this change

Every ElementRenderer still consumes the action context for execute, and that context value changes while a custom action handler runs: loadingActions is set before the handler and cleared after it (pendingConfirmation changes too for confirmed actions). Going by actions.tsx, each custom handler call should therefore re-run every element twice. I have not measured it. Built-in actions such as setState return before that point. Splitting a stable execute out of the context would address it, but it is a separate change.

🤖 Generated with Claude Code

Every ElementRenderer read the whole state through StateContext and
VisibilityContext, so any state write re-ran prop resolution and
visibility for every element in the spec, even for paths nobody reads.

Each element now collects the state paths its expressions read ($state,
$bindState, $bindItem, $template, visibility conditions and watch keys)
and subscribes to the store through useSyncExternalStore with a selector
over those paths. Elements using a custom directive keep subscribing to
the whole state model, since a directive can read anything. RepeatChildren
subscribes to its statePath, and the repeat scope value is memoized so
unchanged items skip re-rendering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

@shteynu is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@shteynu

shteynu commented Oct 8, 2026

Copy link
Copy Markdown
Author

@Railly @ctate — this builds directly on #325: that PR made spec changes cheap, this one does the same for state writes. With 1,005 elements a write to one path re-ran 1,013 element renderers before and 1 after (~20× faster per write); details and tests are in the description.

Two things need someone from the team: the Vercel preview deploys are waiting on team authorization, and the GitHub Actions run needs approval since this comes from a fork. Happy to adjust the approach if you'd rather split it differently, for example landing the useStateSubscription change in react-state separately.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant