|
| 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 | + |
0 commit comments