Skip to content

Fix useList renders all records on first render - #11441

Open
fzaninotto wants to merge 5 commits into
masterfrom
fix/uselist-first-render
Open

fzaninotto wants to merge 5 commits into
masterfrom
fix/uselist-first-render

Conversation

@fzaninotto

@fzaninotto fzaninotto commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Problem

useList stored the paginated data in a state initialized with the raw data, and only applied filtering, sorting and pagination in a useEffect. As a consequence, the first render exposed the whole unfiltered, unsorted and unpaginated array. Every child of the ListContext mounted once per record (and fired getMany calls for the references of all records) before a second render displayed the expected page.

Closes #11440

Solution

  • Derive the paginated data during render with useMemo instead of storing it in a state updated by an effect. The first render now shows the right page, and the double render is gone.
  • Make sure every returned value matches ListControllerResult:
    • while pending, data and total are undefined (the previous code could keep a stale total)
    • when data is missing outside of loading, return an empty list with total: 0 (it used to return total: undefined, which made <Pagination> display a bogus "1-10 of 10" below an empty list)
  • When the filter prop changes, update the filter values during render instead of in an effect, as recommended by the React docs. This applies to both useList and useReferenceManyFieldController, which had the same effect: children rendered once with records matching the previous filter, and <ReferenceManyField> fetched with the previous filter before fetching with the new one.
  • When the filter prop changes, go back to the first page, as setFilters already does. Otherwise the current page could end up past the new total and display an empty list.
  • Apply the same fix to useSortState and usePaginationState (used by useList and useReferenceManyFieldController), which synced their sort and perPage props in an effect guarded by an isFirstRender ref. When perPage went from 50 to 5, 50 rows were mounted once, with their reference getMany calls, before the page size was updated. The existing semantics are preserved: changing perPage resets the page to 1, falls back to 25 when perPage is removed, and the page prop is still not synced.
  • Sort a copy of the records, so that useList never mutates the data prop (this happened when filter was null), which matters now that the computation runs during render.

Note on render-phase updates

When a prop changes, React still runs the body of the component calling the hook once with the previous state, then discards that pass and re-runs it with the updated state. This discarded pass is never committed: children are not rendered and effects don't run, so descendants never see the previous filter, sort or page size. Only code in the host component that has side effects during render could observe it. Before this PR, the stale render was fully committed, with all its children and effects.

How to test

  • New unit tests in useList.spec.tsx cover the first render with filter, sort and pagination, the non-mutation of data, missing data, the pending state, the absence of a render with stale filters when the filter prop changes, and the return to the first page in that case.
  • A new test in useReferenceManyFieldController.spec.tsx checks that a filter prop change triggers a single fetch, for the first page with the new filter.
  • New tests in useSortState.spec.tsx and usePaginationState.spec.tsx (renamed from .ts to render a child component) check that children never render with the previous sort or page size after a prop change, and that the existing semantics are preserved.

Additional Checks

  • The PR targets master for a bugfix or a documentation fix, or next for a feature
  • The PR includes unit tests (if not possible, describe why)
  • The PR includes one or several stories (if not possible, describe why)
  • The documentation is up to date

🤖 Generated with Claude Code

useList stored the paginated data in a state initialized with the raw data,
and only applied filters, sort and pagination in an effect. The first render
exposed the whole unfiltered array, which mounted every child (and fired
getMany calls for all references) before rendering the expected page.

The paginated data is now derived during render with useMemo. This also
ensures the result always matches ListControllerResult: no stale total while
pending, and an empty list with a total of 0 when data is missing. Sorting
now works on a copy so it never mutates the data prop.

Closes #11440

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fzaninotto
fzaninotto force-pushed the fix/uselist-first-render branch from 699a0ee to b8df854 Compare November 6, 2026 21:49
fzaninotto and others added 4 commits November 6, 2026 23:01
When the filter prop changed, useList synced filterValues in an effect, so
children were rendered once with records matching the previous filter.
Adjusting the state during render lets React restart the render before
committing it, as recommended by the React docs for resetting state when a
prop changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Setting state during render is counter-intuitive, so point to the React
docs that recommend this pattern for adjusting state when a prop changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useSortState and usePaginationState synced their initial value prop in an
effect guarded by an isFirstRender ref. When the sort or perPage prop
changed, children were rendered once with the previous sort or page size
(e.g. 50 rows mounted with their reference getMany calls when perPage went
from 50 to 5) before the effect updated the state.

They now adjust the state during render, so React restarts the render
before committing it. The existing semantics are preserved: changing
perPage resets the page to 1, falls back to 25 when perPage is removed, and
the page prop is still not synced.

The specs are renamed to .tsx to render a child component in the tests.

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

useReferenceManyFieldController still synced its filter prop in an effect,
so children rendered once and a fetch went out with the previous filter.
Changing the filter prop also kept the current page, which could end up past
the new total and show an empty list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
// restart the render before committing it, so that children never see
// the previous perPage.
// @see https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
if (initialPagination.perPage !== prevPerPage) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If perPage become NaN, then we will get an infinite render loop, Because NaN !== NaN is always true.
We need to guard agains it.

Suggested change
if (initialPagination.perPage !== prevPerPage) {
if (a !== b && !(Number.isNaN(a) && Number.isNaN(b))) {

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.

useList renders all records on first render, ignoring perPage

2 participants