Skip to content

perf(List): skip re-rendering unchanged list items - #3581

Merged
mfal merged 8 commits into
nextfrom
perf/list-items-skip-unchanged
Oct 27, 2026
Merged

mfal merged 8 commits into
nextfrom
perf/list-items-skip-unchanged

Conversation

@mfal

@mfal mfal commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

A re-render of a list's parent, or a refetch that hands List.StaticData a new array of the same items, no longer re-renders the items of a list or tile view. Three changes:

  1. Item components read the list model from a stable store. List rebuilds its model on every render, and the model is the list context's value. Every consumer re-rendered with each render, including the ones inside items that skip their own render: ListItemView, and every Button in an item through its size dynamic prop. A second context now holds a store that receives the model once the list has committed. An internal useListSelector reads it and re-renders only when the value it reads changes; ListItemView uses it for the view mode. useList is unchanged.
  2. ListItemView and ListItemViewContent build their props contexts once. Both held only constants (tunnels, class names, an OptionsButton element, dynamic props) but were rebuilt on every render, which handed every item's content a new props context. ListItemView keeps one per view mode and hands the item buttons a static size.
  3. Items skips rendering while nothing it shows has changed. react-aria re-renders every GridListItem whenever the GridList renders, Flow's item memo or not. Items now returns its last element while these stay equal:
    • loading state, onAction, the infinite scroll trigger, accordion, tileMaxWidth
    • every entry's id and data, and the list props
    • every item rendering the same by the item memo's rule (ItemView.rendersSameFor)

The table counterpart is #3575.

It builds on #3579: without its stable ClearPropsContext, every render of List hands the items a new props context, and they re-render despite the changes here.

Related: #3575

Effect

Measured in mStudio, dev build, React Profiler render time and number of component renders summed over one refetch, median of 7. Baseline is next with #3575 and #3579; after adds this PR.

List Refetch before after
domains, 185 items, List.StaticData only the list's query 353 ms / 42,617 renders 25 ms / 623 renders
every query on the page 896 ms / 87,622 320 ms / 3,638
projects, 67 items every query on the page 690 ms / 40,007 288 ms / 21,767
mail addresses, 35 items every query on the page 59 ms / 1,409 62 ms / 1,409

Refetching only the projects' or mail addresses' list query was already ~1 ms with #3575, since their loaders keep the same items.

Selecting an item in Nexus' ticket list (list view, 100 entries, selection controlled by the parent), same baseline, dev build, render time summed over all commits of one click, median of 6 real mouse clicks:

before after
one click 278 ms / 25,500 renders 127 ms / 3,000 renders

Before, the click rendered every item twice. What remains is mostly the one render of the list and each item's react-aria Button re-rendering on its own state.

Tests

List.browser.test.tsx (Item re-renders): counts react-aria's item renders through an ItemsGridListItem view whose children function runs on each of them, and the item content's renders through a component reading the props context. Two parent re-renders render neither; a change to renderDependencies still renders the item.

The test fails without the Items change and without the stable list store (2 item renders instead of 0, verified by reverting each). A second test toggles a button's size through a nested props context inside an item, which used to change the button's hook count. The existing List tests for filters, saved settings, the date range filter and the view mode cover the useList consumers that still have to follow every change.

NestedFormSubmit.browser.test.tsx now waits for the Modal before typing. Typed into before the remote Field was wired up, the value never reached the remote form, and its required rule blocked the submit. The race is in the test, not in this PR: under CPU load it fails on next as well.

🤖 Generated with Claude Code

mfal and others added 3 commits October 26, 2026 14:00
The list model is rebuilt on every render of List, and it was the
context value. Every useList consumer re-rendered with each render,
including the ones inside items that skip their own render.

The context now holds a store that keeps its identity. useList
subscribes to it and still re-renders with every new model, so its
consumers behave as before. The new useListSelector re-renders only
when the value it reads changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ListItemView and ListItemViewContent built a new props context on every
render, so every item's content re-rendered with them. Both contexts
hold only constants and are module-level now. ListItemView reads the
view mode through useListSelector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
react-aria re-renders every GridList item whenever the GridList
renders, Flow's item memo or not. The items now return their last
element while the loading state, onAction, the infinite scroll
trigger, every entry's id and data and the list props stay equal and
every item renders the same by the item memo's rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mfal
mfal requested review from a team and Lisa18289 October 26, 2026 13:16
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

Type URL
docs pr-3581.docs.review.flow-components.de
storybook pr-3581.storybook.review.flow-components.de

Images:

  • docs: ghcr.io/mittwald/flow/docs:pr-3581
  • storybook: ghcr.io/mittwald/flow/storybook:pr-3581

mfal and others added 2 commits October 27, 2026 07:34
The default list context now holds a store without a list, so both hooks
return undefined again instead of throwing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mfal and others added 3 commits October 27, 2026 10:42
… statically

The store behind `useListSelector` received the new model during render, so
a render React throws away left an uncommitted model in it. It now gets the
model in the layout effect, and `useList` reads the render-time model from
the list context again.

`ListItemView` read the view mode inside the button's dynamic `size`, which
only runs while the button's props context still holds that `size`. Now that
the read is a hook, a button whose context dropped or regained it rendered
with a different hook count. `ListItemView` reads the view mode once and
hands the button a static size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…skip-unchanged

# Conflicts:
#	packages/components/src/components/List/List.browser.test.tsx
Filled before the remote Field was wired up, the value never reached the
remote form, so its required rule blocked the submit on slow runners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mfal
mfal enabled auto-merge October 27, 2026 11:33
@mfal
mfal merged commit 01fe3b4 into next Oct 27, 2026
29 of 30 checks passed
@mfal
mfal deleted the perf/list-items-skip-unchanged branch October 27, 2026 12:19
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.

2 participants