> _ VG.dev
~/home~/projects~/blog~/about
~/blog
react-performance-dashboards.md

title: "React Performance Patterns for Data-Heavy Dashboards"

date: 2026-04-24

readingTime: 8 min read

tags: ["React", "Performance", "Frontend"]

React Performance Patterns for Data-Heavy Dashboards

April 24, 20268 min read
ReactPerformanceFrontend

React Performance Patterns for Data-Heavy Dashboards

After building analytics dashboards displaying 10,000+ rows of financial data, I've learned that React's default rendering strategy doesn't scale without optimization.

This post covers the patterns that actually improved performance in production—backed by metrics, not theory.

The Problem

Our trading dashboard displays:

  • 5,000+ transactions in a table
  • Real-time updates (WebSocket)
  • Multiple filters and sorts
  • Charts with 1,000+ data points
  • Sub-second update requirements

Initial performance:

  • Initial render: 3.2 seconds
  • Filter change: 1.8 seconds
  • Scroll: 15 FPS (unusable)
  • Memory: 450MB

Target:

  • Initial render: <500ms
  • Filter change: <100ms
  • Scroll: 60 FPS
  • Memory: <150MB

Here's what worked.

Pattern 1: Virtualization (The Biggest Win)

Problem: Rendering 5,000 DOM nodes is slow.

Solution: Only render visible rows.

// Before: Render all rows
function TransactionTable({ transactions }: { transactions: Transaction[] }) {
  return (
    <table>
      <tbody>
        {transactions.map(tx => (
          <TransactionRow key={tx.id} transaction={tx} />
        ))}
      </tbody>
    </table>
  );
}

// After: Virtualize with react-window
import { FixedSizeList } from 'react-window';

function TransactionTable({ transactions }: { transactions: Transaction[] }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={transactions.length}
      itemSize={35}
      width="100%"
    >
      {({ index, style }) => (
        <TransactionRow
          key={transactions[index].id}
          transaction={transactions[index]}
          style={style}
        />
      )}
    </FixedSizeList>
  );
}

Results:

  • Initial render: 3.2s → 280ms
  • Memory: 450MB → 120MB
  • Scroll: 15 FPS → 60 FPS

Libraries:

  • react-window (simple lists)
  • react-virtualized (complex layouts)
  • tanstack-virtual (framework agnostic)

Pattern 2: Memoization (Use Wisely)

Problem: Parent re-render causes all children to re-render.

Solution: React.memo + useMemo + useCallback

// Without memoization
function TransactionRow({ transaction, onSelect }: Props) {
  return (
    <tr onClick={() => onSelect(transaction.id)}>
      <td>{transaction.date}</td>
      <td>{transaction.amount}</td>
      <td>{transaction.description}</td>
    </tr>
  );
}

// With memoization
const TransactionRow = React.memo(({ transaction, onSelect }: Props) => {
  return (
    <tr onClick={() => onSelect(transaction.id)}>
      <td>{transaction.date}</td>
      <td>{transaction.amount}</td>
      <td>{transaction.description}</td>
    </tr>
  );
}, (prevProps, nextProps) => {
  // Custom comparison
  return (
    prevProps.transaction.id === nextProps.transaction.id &&
    prevProps.transaction.amount === nextProps.transaction.amount
  );
});

// Parent component
function TransactionTable({ transactions }: Props) {
  // Memoize callback to prevent child re-renders
  const handleSelect = useCallback((id: string) => {
    setSelectedId(id);
  }, []);
  
  return (
    <FixedSizeList ...>
      {({ index, style }) => (
        <TransactionRow
          key={transactions[index].id}
          transaction={transactions[index]}
          onSelect={handleSelect}
          style={style}
        />
      )}
    </FixedSizeList>
  );
}

When to use:

  • ✅ Expensive components (complex rendering)
  • ✅ Stable props (rarely change)
  • ✅ Many instances (lists, grids)

When NOT to use:

  • ❌ Simple components (overhead > benefit)
  • ❌ Props change every render (memoization wasted)
  • ❌ Premature optimization (measure first)

Results:

  • Filter change: 1.8s → 120ms
  • Unnecessary re-renders: 95% → 5%

Pattern 3: Pagination vs Infinite Scroll

Problem: Even with virtualization, 50,000 rows is too much.

Solution: Server-side pagination

function usePaginatedData(
  queryKey: string,
  fetchFn: (page: number) => Promise<Data[]>
) {
  const [page, setPage] = useState(0);
  const [allData, setAllData] = useState<Data[]>([]);
  const [hasMore, setHasMore] = useState(true);
  
  const { data, isLoading } = useQuery({
    queryKey: [queryKey, page],
    queryFn: () => fetchFn(page),
  });
  
  useEffect(() => {
    if (data) {
      setAllData(prev => [...prev, ...data]);
      setHasMore(data.length === PAGE_SIZE);
    }
  }, [data]);
  
  return { data: allData, isLoading, hasMore, loadMore: () => setPage(p => p + 1) };
}

// Usage
function TransactionDashboard() {
  const { data, isLoading, hasMore, loadMore } = usePaginatedData(
    'transactions',
    (page) => api.getTransactions({ page, pageSize: 100 })
  );
  
  return (
    <>
      <TransactionTable transactions={data} />
      {hasMore && (
        <button onClick={loadMore} disabled={isLoading}>
          {isLoading ? 'Loading...' : 'Load More'}
        </button>
      )}
    </>
  );
}

Results:

  • Initial load: 280ms → 95ms (first 100 rows only)
  • Memory: 120MB → 45MB
  • User perceived performance: Much better

Pattern 4: Debounced Filters

Problem: Filter changes trigger expensive re-renders on every keystroke.

Solution: Debounce user input

import { useDebounce } from 'use-debounce';

function DashboardFilters({ onFilterChange }: Props) {
  const [searchTerm, setSearchTerm] = useState('');
  const [debouncedSearch] = useDebounce(searchTerm, 300);
  
  useEffect(() => {
    onFilterChange({ search: debouncedSearch });
  }, [debouncedSearch, onFilterChange]);
  
  return (
    <input
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Search transactions..."
    />
  );
}

Results:

  • Filter changes: 120ms → 35ms
  • API calls reduced: 10 per second → 1 per 300ms

Pattern 5: Web Workers for Heavy Computation

Problem: Sorting 10,000 rows blocks the main thread (UI freezes).

Solution: Move computation to Web Worker

// worker.ts
self.onmessage = (e) => {
  const { transactions, sortBy } = e.data;
  const sorted = [...transactions].sort((a, b) => {
    if (sortBy === 'date') return a.date.localeCompare(b.date);
    if (sortBy === 'amount') return a.amount - b.amount;
    return 0;
  });
  self.postMessage(sorted);
};

// React component
function TransactionTable({ transactions, sortBy }: Props) {
  const [sortedTransactions, setSortedTransactions] = useState(transactions);
  const workerRef = useRef<Worker | null>(null);
  
  useEffect(() => {
    workerRef.current = new Worker(new URL('./worker.ts', import.meta.url));
    
    workerRef.current.onmessage = (e) => {
      setSortedTransactions(e.data);
    };
    
    return () => workerRef.current?.terminate();
  }, []);
  
  useEffect(() => {
    workerRef.current?.postMessage({ transactions, sortBy });
  }, [transactions, sortBy]);
  
  return <VirtualizedTable data={sortedTransactions} />;
}

Results:

  • Sort operation: 800ms (blocked) → 150ms (non-blocking)
  • UI remains responsive during sort

Pattern 6: Optimistic Updates

Problem: Waiting for server response before updating UI feels slow.

Solution: Update UI immediately, rollback on error

function useOptimisticUpdate() {
  const queryClient = useQueryClient();
  
  const updateTransaction = useMutation({
    mutationFn: (update: TransactionUpdate) => 
      api.updateTransaction(update),
    
    // Optimistic update
    onMutate: async (newData) => {
      await queryClient.cancelQueries({ queryKey: ['transactions'] });
      
      const previousData = queryClient.getQueryData(['transactions']);
      
      queryClient.setQueryData(['transactions'], (old: any) => ({
        ...old,
        transactions: old.transactions.map((tx: Transaction) =>
          tx.id === newData.id ? { ...tx, ...newData } : tx
        )
      }));
      
      return { previousData };
    },
    
    // Rollback on error
    onError: (err, newData, context) => {
      queryClient.setQueryData(['transactions'], context.previousData);
    },
    
    // Refetch to ensure consistency
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['transactions'] });
    }
  });
  
  return updateTransaction;
}

Results:

  • Perceived latency: 200ms → 0ms (instant feedback)
  • User satisfaction: Significantly improved

Pattern 7: Selective Re-renders with Context

Problem: Global context causes all components to re-render on any change.

Solution: Split contexts by concern

// Bad: Single large context
const DashboardContext = createContext({
  transactions: [],
  filters: {},
  user: null,
  settings: {},
  // ... everything
});

// Any change causes all consumers to re-render

// Good: Split contexts
const TransactionsContext = createContext({ transactions: [] });
const FiltersContext = createContext({ filters: {} });
const UserContext = createContext({ user: null });
const SettingsContext = createContext({ settings: {} });

// Components only subscribe to what they need
function TransactionRow({ id }: Props) {
  const { transactions } = useContext(TransactionsContext);
  // Only re-renders when transactions change, not filters/user/settings
}

Results:

  • Context updates: 100% re-render → 10-20% re-render
  • Overall performance: 40% improvement

Pattern 8: Lazy Loading Charts

Problem: Chart libraries are heavy (200KB+), slow initial load.

Solution: Lazy load chart components

import { lazy, Suspense } from 'react';

const RevenueChart = lazy(() => import('./RevenueChart'));
const VolumeChart = lazy(() => import('./VolumeChart'));

function Dashboard() {
  return (
    <div>
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart data={revenueData} />
      </Suspense>
      
      <Suspense fallback={<ChartSkeleton />}>
        <VolumeChart data={volumeData} />
      </Suspense>
    </div>
  );
}

Results:

  • Initial bundle: 1.2MB → 650KB
  • Time to interactive: 2.1s → 1.1s

Pattern 9: Efficient State Updates

Problem: Unnecessary state updates trigger re-renders.

Solution: Batch updates and avoid stale state

// Bad: Multiple state updates
function updateTransaction(id: string, updates: Partial<Transaction>) {
  setTransactions(prev => prev.map(tx => 
    tx.id === id ? { ...tx, ...updates } : tx
  ));
  setSelectedId(id);
  setLastUpdated(new Date());
  // Each triggers separate re-render
}

// Good: Batch updates
function updateTransaction(id: string, updates: Partial<Transaction>) {
  ReactDOM.unstable_batchedUpdates(() => {
    setTransactions(prev => prev.map(tx => 
      tx.id === id ? { ...tx, ...updates } : tx
    ));
    setSelectedId(id);
    setLastUpdated(new Date());
  });
  // Single re-render
}

// Better: Use state management library
const useTransactionStore = create((set) => ({
  transactions: [],
  updateTransaction: (id, updates) => 
    set((state) => ({
      transactions: state.transactions.map(tx =>
        tx.id === id ? { ...tx, ...updates } : tx
      )
    }))
}));

Performance Monitoring

React DevTools Profiler

import { Profiler } from 'react';

function onRenderCallback(
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) {
  console.log(`${id} took ${actualDuration}ms to render`);
}

function App() {
  return (
    <Profiler id="Dashboard" onRender={onRenderCallback}>
      <TransactionDashboard />
    </Profiler>
  );
}

Custom Performance Hook

function useRenderTime(componentName: string) {
  const start = performance.now();
  
  useEffect(() => {
    const end = performance.now();
    console.log(`${componentName} rendered in ${end - start}ms`);
  });
}

// Usage
function TransactionTable() {
  useRenderTime('TransactionTable');
  // ...
}

Final Results

After implementing all patterns:

| Metric | Before | After | Improvement | |--------|--------|-------|-------------| | Initial render | 3.2s | 95ms | 97% faster | | Filter change | 1.8s | 35ms | 98% faster | | Scroll FPS | 15 | 60 | 4x smoother | | Memory usage | 450MB | 45MB | 90% less | | Bundle size | 1.2MB | 650KB | 46% smaller |

Key Takeaways

  1. Virtualization is non-negotiable for large lists
  2. Measure before optimizing—don't guess
  3. Memoization helps, but don't overuse it
  4. Web Workers for heavy computation
  5. Lazy load heavy components (charts, maps)
  6. Split contexts to avoid unnecessary re-renders
  7. Server-side pagination beats client-side for huge datasets
  8. Monitor in production with real user metrics

Building data-heavy dashboards? Happy to share more specific patterns. Find me on GitHub.

You might also like

2026-04-19

SQL Server Indexing Strategies That Actually Work in Production

7 min read

2026-04-15

Understanding React Hooks: A Comprehensive Guide

2 min read

2026-04-14

SQL Server Performance Tuning: Lessons from ERP Systems

5 min read
PreviousNext