DataGrid Pro Stability: Preventing Infinite Loops and Cascade Updates
Published: 2025-01-16
When the LLM Council reviewed our DataGrid Pro implementation (ADR-035), they identified critical stability issues: missing getRowId props causing row identity recreation, unstable query keys triggering unnecessary refetches, and unthrottled filter/sort handlers causing cascade updates.
This post details how Issue #462 fixed these DataGrid stability issues.
The Problem
Problem 1: Missing getRowId Causes Row Identity Recreation
Without an explicit getRowId prop, DataGrid Pro uses array index as the row identifier:
// Before: Row identity based on array index
<DataGridPremium
rows={data}
columns={columns}
/>
When the data array is recreated (even with the same values), DataGrid treats all rows as new, causing:
- Loss of selection state
- Scroll position reset
- Unnecessary DOM reconciliation
- Potential infinite loops with controlled components
Problem 2: Unstable Query Keys
React Query triggers refetches when query key references change:
// Problematic: New object reference every render
const queryKey = ['entities', entityType, { page, filters }];
Combined with DataGrid's server-side pagination callbacks, this creates a feedback loop:
- DataGrid triggers
onPageChange - New query key object created
- React Query refetches
- New data causes DataGrid to re-render
- Goto step 1
Problem 3: Unthrottled Filter/Sort Handlers
DataGrid fires onFilterModelChange and onSortModelChange rapidly during user interaction:
// Before: Every keystroke triggers API call
const handleFilterChange = (model) => {
setFilters(model); // Immediate state update
refetch(); // Immediate API call
};
This causes:
- Excessive API calls during typing
- UI lag from rapid state updates
- Server load from redundant requests
The Solution
1. Explicit getRowId Prop
Added getRowId to all DataGrid instances:
<DataGridPremium
// ADR-035: Explicit getRowId prevents row identity recreation
getRowId={(row) => row.id}
rows={safeData}
columns={columns}
/>
Why this works: The entity's actual ID (UUID or database ID) is used as the row key instead of array position. Row identity is stable across data updates.
2. Stable Query Key Hook
Created useStableQueryKey for deep comparison of query keys:
// frontend/src/hooks/useStableQueryKey.ts
export function useStableQueryKey<T>(queryKey: T): T {
const keyRef = useRef<T>(queryKey);
return useMemo(() => {
// Only update reference if values actually changed
if (!isEqual(keyRef.current, queryKey)) {
keyRef.current = queryKey;
}
return keyRef.current;
}, [queryKey]);
}
Usage:
const queryKey = useStableQueryKey(['entities', entityType, filters]);
// queryKey reference only changes when values actually differ
3. Throttled Handlers
Created useThrottle hook for rate-limited callbacks:
// frontend/src/hooks/useThrottle.ts
export function useThrottle<T extends (...args: any[]) => void>(
callback: T,
delay: number,
options: { leading?: boolean; trailing?: boolean } = {}
): T {
// Throttle implementation with leading/trailing edge support
}
Applied to DataGrid:
const handleFilterChangeInternal = useCallback((model) => {
setFilters(model);
onFilterChange?.(model);
}, [onFilterChange]);
// ADR-035: Throttle to 300ms prevents cascade updates
const handleFilterChange = useThrottle(handleFilterChangeInternal, 300, {
leading: true,
trailing: true
});
<DataGridPremium
onFilterModelChange={handleFilterChange}
/>
4. URL State Synchronization
Created useGridUrlState for shareable grid configurations:
// frontend/src/hooks/useGridUrlState.ts
export function useGridUrlState(defaults) {
const [searchParams, setSearchParams] = useSearchParams();
// Parse pagination, sort, search from URL
const state = useMemo(() => ({
pagination: { page, pageSize },
sort: [{ field, sort }],
search
}), [searchParams]);
// Update URL without navigation
const setPagination = (model) => {
setSearchParams((prev) => {
prev.set('page', model.page);
return prev;
}, { replace: true });
};
return { state, setPagination, setSort, setSearch, getApiParams };
}
Benefits:
- Grid state preserved across page refreshes
- Shareable URLs with filters/pagination
- Browser back/forward navigation support
Implementation
Files Created
frontend/src/hooks/useStableQueryKey.tsfrontend/src/hooks/useThrottle.tsfrontend/src/hooks/useGridUrlState.tsfrontend/e2e/datagrid-stability.spec.tsfrontend/src/hooks/__tests__/useStableQueryKey.test.tsfrontend/src/hooks/__tests__/useThrottle.test.ts
Files Modified
-
frontend/src/components/tables/MUIEntityTable.tsx- Added
getRowId={(row) => row.id} - Added throttled filter/sort handlers
- Imported and applied
useThrottle
- Added
-
frontend/src/components/DataGridWrapper.tsx- Added
getRowId={(row) => row.id} - Added memoized rows with
useMemo - Added throttled handlers
- Added
Impact
| Metric | Before | After |
|---|---|---|
| Row identity stability | Index-based | ID-based |
| Filter change API calls | 1 per keystroke | Max 3 per second |
| Query key stability | New ref each render | Stable until values change |
| URL state persistence | None | Full pagination/sort/search |
| ADR-035 verdict | CONDITIONAL | APPROVED |
Key Takeaways
- Always specify getRowId: DataGrid needs stable row identity for controlled components
- Stabilize query keys: Use deep comparison to prevent unnecessary refetches
- Throttle user interactions: Rate-limit handlers that trigger API calls
- URL state enables sharing: Sync grid state to URL for better UX
Issue #462 | ADR-035 | LLM Council Blocking Issue Resolved