title: "React Performance Patterns for Data-Heavy Dashboards"
date: 2026-04-24
readingTime: 8 min read
tags: ["React", "Performance", "Frontend"]
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.
Our trading dashboard displays:
Initial performance:
Target:
Here's what worked.
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:
Libraries:
react-window (simple lists)react-virtualized (complex layouts)tanstack-virtual (framework agnostic)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:
When NOT to use:
Results:
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:
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:
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:
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:
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:
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:
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
)
}))
}));
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>
);
}
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');
// ...
}
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 |
Building data-heavy dashboards? Happy to share more specific patterns. Find me on GitHub.