Skip to content

Commit 232f35d

Browse files
authored
Fix Total Row Count on Grid (#1726)
### **Row count not updating on client-side filter** When all pages were cached (isCacheComplete), the grid switched to client-side filtering but rowCount still reflected the server total. The fix subscribes to the grid's internal state via useSyncExternalStore + gridFilteredTopLevelRowCountSelector so the toolbar's "Total Rows" count updates reactively as filters are applied. The subscription is extracted into a reusable useGridFilteredRowCount hook. Also fixes Toolbar.tsx using && on rowCount, which suppressed the label when the count was 0. ### **Grid flashing blank on filter change** **Two causes:** paginationMode was made dynamic based on isCacheComplete. When switching from a fully-cached filter to an uncached one, isCacheComplete flips and MUI DataGrid received paginationMode, filterMode, sortingMode, and rowsLoadingMode all changing in the same render — triggering an internal grid reset. paginationMode is now stable again. While a filter refetch was in flight, rows was cleared to [], blanking the grid. Now it falls back through Apollo's previousData → a prevListRef (last known list), keeping the grid populated under the loading indicator. The prevListRef covers the skip→active transition case where previousData is unavailable (e.g. switching from a fully-cached filter to a new one). ### Refactors in useDataGridSource queryForItemRef: useMemo(…, []) + eslint-disable → useRef with IIFE (one-time init, not derived state) addToAllPagesCache: wrapped in useMemoizedFn for a stable reference; useEffect dep list is now correct and its eslint-disable is removed const mode: collapsed three repeated isCacheComplete ? 'client' : 'server' ternaries Renamed firstPage/lastPage inside onFetchRows to startPage/endPage (shadowed the outer firstPage data variable) Restored and documented local NoInfer<T> — the TS 5.4 built-in doesn't resolve through property access on constrained generics in this pattern
1 parent 1418058 commit 232f35d

File tree

6 files changed

+585
-216
lines changed

6 files changed

+585
-216
lines changed

docs/data-grid-source.md

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
# useDataGridSource
2+
3+
A React hook that connects a GraphQL-backed list to an MUI DataGrid Pro. It
4+
handles server-side paging, incremental cache accumulation, and an automatic
5+
switch to fully client-side filtering and sorting once every row has been
6+
loaded.
7+
8+
---
9+
10+
## Non-technical overview
11+
12+
Every list screen in the app — Projects, Partners, Languages, etc. — shows a
13+
table of rows. Those rows live on the server, often in the thousands, so the
14+
app can't download them all at once. Instead it fetches one page at a time as
15+
the user scrolls.
16+
17+
At the same time, filters and sorts the user applies need to feel instant.
18+
Waiting for a server round-trip every time someone clicks a column header is
19+
too slow. So the hook strikes a balance:
20+
21+
- **Before all rows are loaded:** sort and filter requests go to the server,
22+
which returns the right page.
23+
- **After all rows are loaded** (either because the list is small, or because
24+
the user has scrolled through everything): the app switches to doing its
25+
own sorting and filtering in the browser, with no extra network calls.
26+
27+
The user's sort and filter choices are saved to `localStorage`, scoped to their
28+
account, so they survive page refreshes and signing out and back in. When the
29+
user comes back, the table reopens exactly as they left it.
30+
31+
---
32+
33+
## Architecture
34+
35+
Three files collaborate:
36+
37+
| File | Responsibility |
38+
|---|---|
39+
| `useViewState.ts` | What the user is looking at (sort column, filter values). Persists to `localStorage`. Converts UI state into GraphQL variables. |
40+
| `useDataGridSource.tsx` | Orchestrator. Calls the two sub-hooks, handles sort/filter change events, and assembles the final props for `<DataGrid>`. |
41+
| `useGridFilteredRowCount.ts` | Reactively reads how many rows pass the grid's current client-side filter, used for the "Total Rows" counter. |
42+
43+
```
44+
useDataGridSource
45+
├── useViewState ← "what are we looking at?"
46+
│ ├── localStorage ← persist sort + filter across refreshes
47+
│ └── GraphQL input ← sort/filter → { sort, order, filter } for the query
48+
├── useCachedList ← "fetch it and keep it"
49+
│ ├── allPages query ← cache-only read of the accumulated full list
50+
│ ├── firstPage query ← live query for page 1 (current sort/filter)
51+
│ ├── onFetchRows ← triggered by scroll; fetches pages 2, 3, …
52+
│ └── row stability ← prevents blank flash during loading transitions
53+
└── useGridFilteredRowCount ← reactive client-side row count
54+
```
55+
56+
---
57+
58+
## useViewState
59+
60+
**What it owns:** sort model, filter model, localStorage persistence, and the
61+
conversion of those UI values into the `{ sort, order, filter }` input the
62+
GraphQL query expects.
63+
64+
### Storage key
65+
66+
The key is `<userId>:<operationName>-data-grid-view`, scoped to both the
67+
authenticated user and the query name so different grids don't collide and
68+
different users sharing a browser don't see each other's filters.
69+
Example: `abc123:ProjectList-data-grid-view`.
70+
71+
### What is (and isn't) persisted
72+
73+
Only column types that make sense across sessions are saved:
74+
75+
- `singleSelect` columns (dropdown choices) — **persisted**
76+
- `boolean` columns (yes/no toggles) — **persisted**
77+
- Free-text columns — **not persisted** (partial strings often aren't useful
78+
after a break, and they can expose sensitive search terms in browser storage)
79+
80+
Sort state is always persisted.
81+
82+
### The `apiFilterModel` field
83+
84+
The stored object keeps a pre-computed `apiFilterModel` alongside the MUI
85+
`filterModel`. This exists because on the very first render the DataGrid
86+
hasn't mounted yet, so its API object isn't available to compute the filter
87+
on the fly. The pre-computed version is used until the grid is ready.
88+
89+
### `apiSortModel` vs `sortModel`
90+
91+
The view tracks two sort models:
92+
93+
- `sortModel` — what the grid column headers show (always up to date).
94+
- `apiSortModel` — what the first-page API query uses.
95+
96+
When the cache is complete and sorting is done client-side, `apiSortModel`
97+
can be stale without issue — the server doesn't need to be asked again.
98+
When the cache is not yet complete, `apiSortModel` is updated alongside
99+
`sortModel` so the server query uses the current sort.
100+
101+
---
102+
103+
## useCachedList
104+
105+
**What it owns:** all Apollo interaction, page accumulation, and ensuring the
106+
grid always has stable rows to display.
107+
108+
### The two cache queries
109+
110+
```
111+
allPages = cache-only read at variables (no filter)
112+
allFilteredPages = cache-only read at variablesWithFilter (with filter, skipped when no filter)
113+
```
114+
115+
Both are `fetchPolicy: 'cache-only'` — they never hit the network. They just
116+
watch what's already in the Apollo cache.
117+
118+
### How pages accumulate
119+
120+
Every time a page arrives from the network (either the auto-fetched page 1, or
121+
a scroll-triggered page), `addToAllPagesCache` writes it into the Apollo cache
122+
under the `queryForItemRef` key. That key is a trimmed version of the query
123+
that only requests `keyArgs` fields (e.g. `__typename` and `id`), keeping the
124+
accumulated entry small.
125+
126+
The write merges the new items with any that were previously accumulated,
127+
de-duplicating by Apollo cache identity. The `total` field is preserved from
128+
whichever snapshot is authoritative (`updateTotal` flag).
129+
130+
### isCacheComplete
131+
132+
```ts
133+
isCacheComplete = allPages.isComplete || allFilteredPages.isComplete
134+
```
135+
136+
`isComplete` is true when `items.length === total`. Once either the unfiltered
137+
or filtered full list is in cache, the grid switches to client modes for
138+
`rowsLoadingMode`, `sortingMode`, and `filterMode`.
139+
140+
### Row stability (preventing blank flashes)
141+
142+
Loading transitions can leave the grid temporarily with no data. The fallback
143+
chain resolves this:
144+
145+
```
146+
freshList // prefer: current data if not mid-load
147+
?? listFrom(prevFirstPage) // Apollo's previousData during refetch
148+
?? (loading ? prevListRef : null) // skip→active transition (no prevFirstPage)
149+
?? { items: [], total: undefined } // last resort empty state
150+
```
151+
152+
`prevListRef` is updated whenever a fresh list is available, covering the case
153+
where switching from a fully-cached filter to an uncached one means Apollo's
154+
`previousData` is undefined.
155+
156+
### Virtual scroll fetching
157+
158+
`onFetchRows` is called by DataGrid when the user scrolls near unloaded rows.
159+
It is debounced (500 ms) to coalesce rapid scrolling. It:
160+
161+
1. Calculates which pages cover the visible row range.
162+
2. Skips page 1 (always fetched by `useQuery`).
163+
3. Fires `client.query()` for each page in parallel.
164+
4. On success: calls `unstable_replaceRows` to swap in real data for the
165+
skeleton rows, and calls `addToAllPagesCache` to accumulate toward
166+
`isCacheComplete`.
167+
5. Checks `isCurrent` before replacing rows — if sort or filter changed while
168+
the request was in flight, the rows are discarded (but the cache is still
169+
updated).
170+
171+
---
172+
173+
## Mode switching
174+
175+
The grid operates in one of two modes for filtering, sorting, and row loading:
176+
177+
| Mode | When | Behavior |
178+
|---|---|---|
179+
| `server` | `isCacheComplete = false` | DataGrid defers to the app; changes trigger new API queries |
180+
| `client` | `isCacheComplete = true` | DataGrid handles everything in-browser with the full row set |
181+
182+
`paginationMode` is fixed to `server` when a total is known (preventing an
183+
MUI warning) and `client` otherwise. It is intentionally not switched on
184+
`isCacheComplete` — changing `paginationMode` dynamically causes DataGrid to
185+
reset internally, which produces a visible flash.
186+
187+
### Applying client-side sort after cache completion
188+
189+
When `isCacheComplete` flips to `true`, the rows identity changes (switching
190+
from the first-page slice to the full list). DataGrid doesn't automatically
191+
re-sort when both `rows` and mode change at once, so there is an explicit
192+
`useEffect` that calls `apiRef.current.applySorting()` when `isCacheComplete`
193+
becomes true.
194+
195+
---
196+
197+
## Row count
198+
199+
```ts
200+
rowCount = isCacheComplete
201+
? filteredRowCount ?? rows.length
202+
: total
203+
```
204+
205+
- In server mode, `total` comes from the API response.
206+
- In client mode, `filteredRowCount` comes from `useGridFilteredRowCount`,
207+
which subscribes to the DataGrid's internal `stateChange` event via
208+
`useSyncExternalStore` and reads `gridFilteredTopLevelRowCountSelector`.
209+
This gives a live count that updates as filter values change, without any
210+
extra props or state coordination.
211+
212+
---
213+
214+
## Usage example
215+
216+
```tsx
217+
const [dataGridProps] = useDataGridSource({
218+
query: ProjectListDocument,
219+
variables: { input: { filter: { status: ['Active'] } } },
220+
listAt: 'projects',
221+
initialInput: { sort: 'name', order: 'ASC', count: 50 },
222+
});
223+
224+
return <DataGrid {...dataGridProps} columns={columns} />;
225+
```
226+
227+
`listAt` is a dot-separated path into the query result where the
228+
`{ items, total }` object lives. TypeScript enforces that the path resolves
229+
to a valid `PaginatedListOutput` shape, so typos are caught at compile time.
230+

src/components/Grid/Toolbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const Toolbar = (props: ChildrenProp & StyleProps) => {
2424
]}
2525
>
2626
{props.children}
27-
{rootProps.rowCount && (
27+
{rootProps.rowCount != null && (
2828
<Typography>
2929
Total Rows: <FormattedNumber value={rootProps.rowCount} />
3030
</Typography>

src/components/Grid/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './ColumnTypes/booleanNullableColumn';
88
export * from './ColumnTypes/enumColumn';
99
export * from './ColumnTypes/textColumn';
1010
export * from './useDataGridSource';
11+
export * from './useViewState';
1112
export * from './Toolbar';
1213
export * from './QuickFilters';
1314
export * from './ColumnTypes/multiEnumColumn';

0 commit comments

Comments
 (0)