Skip to main content

DataGrid Pro Stability: Preventing Infinite Loops and Cascade Updates

· 4 min read

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:

  1. DataGrid triggers onPageChange
  2. New query key object created
  3. React Query refetches
  4. New data causes DataGrid to re-render
  5. 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.ts
  • frontend/src/hooks/useThrottle.ts
  • frontend/src/hooks/useGridUrlState.ts
  • frontend/e2e/datagrid-stability.spec.ts
  • frontend/src/hooks/__tests__/useStableQueryKey.test.ts
  • frontend/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
  • frontend/src/components/DataGridWrapper.tsx

    • Added getRowId={(row) => row.id}
    • Added memoized rows with useMemo
    • Added throttled handlers

Impact

MetricBeforeAfter
Row identity stabilityIndex-basedID-based
Filter change API calls1 per keystrokeMax 3 per second
Query key stabilityNew ref each renderStable until values change
URL state persistenceNoneFull pagination/sort/search
ADR-035 verdictCONDITIONALAPPROVED

Key Takeaways

  1. Always specify getRowId: DataGrid needs stable row identity for controlled components
  2. Stabilize query keys: Use deep comparison to prevent unnecessary refetches
  3. Throttle user interactions: Rate-limit handlers that trigger API calls
  4. URL state enables sharing: Sync grid state to URL for better UX

Issue #462 | ADR-035 | LLM Council Blocking Issue Resolved