React Dashboard Best Practices
- React
- Dashboard
- TypeScript
- Performance
React dashboards fail in predictable ways. They start fast and become slow. They start simple and become impossible to maintain. They work great in development and crawl in production with real data volumes. Most of these failures come from a handful of architectural decisions made in the first two weeks that are very hard to undo later.
This post covers the practices we have found most valuable after building analytics tools, admin interfaces, and operational dashboards across dozens of products.
Design the Data Layer First
The single biggest mistake in dashboard projects is starting with the UI. You pick a charting library, build a beautiful chart component, and then figure out how to get data into it. This approach causes pain because the chart's expected data shape rarely matches what the API returns.
Start instead with a data contract: for each metric or dataset on the dashboard, define exactly what the API returns, what the client expects, and where the transformation happens. Write these as TypeScript types before writing a single component.
// types/analytics.ts
export interface RevenueMetric {
date: string;
mrr: number;
newMrr: number;
churnedMrr: number;
netNewMrr: number;
}
export interface DashboardSummary {
totalRevenue: number;
activeSubscriptions: number;
churnRate: number;
revenueByMonth: RevenueMetric[];
}
This discipline pays off in two ways: the backend and frontend teams are aligned on shape before a single endpoint is built, and TypeScript catches mismatches at compile time instead of runtime.
Use TanStack Query for All Server State
Every dashboard fetches data. The naive approach — useEffect with fetch — leads to a graveyard of loading, error, and stale-data states scattered across components.
TanStack Query (formerly React Query) centralises this. It gives you caching, background refetching, stale-while-revalidate semantics, and error retry — for free.
import { useQuery } from '@tanstack/react-query';
function useDashboardSummary(workspaceId: string) {
return useQuery({
queryKey: ['dashboard', 'summary', workspaceId],
queryFn: () =>
fetch(`/api/analytics/summary?workspaceId=${workspaceId}`)
.then((r) => r.json() as Promise<DashboardSummary>),
staleTime: 60_000,
refetchInterval: 120_000,
});
}
The queryKey array is important: it determines cache identity. Include every variable that affects the query result (workspace, date range, filter). Changing a key variable triggers an automatic refetch.
Aggregate on the Server, Not the Client
A common performance anti-pattern: send raw rows to the client and aggregate with .reduce() in JavaScript. This works for 500 rows and breaks at 50,000.
Aggregate in your database query. MongoDB's $group and $bucket stages are designed for this. PostgreSQL's window functions handle time-series aggregation in SQL.
// Bad: aggregate 50k rows in the browser
const totalByDay = rawEvents.reduce((acc, event) => {
const day = event.timestamp.slice(0, 10);
acc[day] = (acc[day] ?? 0) + event.value;
return acc;
}, {} as Record<string, number>);
// Good: aggregate in MongoDB
const result = await Event.aggregate([
{ $match: { workspaceId, timestamp: { $gte: start, $lte: end } } },
{
$group: {
_id: { $dateToString: { format: '%Y-%m-%d', date: '$timestamp' } },
total: { $sum: '$value' },
},
},
{ $sort: { _id: 1 } },
]);
The rule: the database returns exactly the shape the chart needs. The client only maps and renders.
Real-Time Updates: Choose the Right Primitive
Not every dashboard needs WebSockets. WebSockets add complexity — connection management, reconnection logic, server-side broadcasting infrastructure. Choose the right primitive:
| Update frequency | Right choice |
|---|---|
| Every 30+ seconds | TanStack Query refetchInterval |
| Every 5–15 seconds | Server-Sent Events (SSE) |
| Sub-second / event-driven | WebSockets |
SSE is frequently overlooked. It is HTTP, works through proxies and Vercel Edge, and requires almost no infrastructure. If your operational dashboard updates every 10 seconds, SSE is the right call.
// server: send SSE events
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const interval = setInterval(() => {
const data = JSON.stringify({ metric: getLatestMetric() });
res.write(`data: ${data}\n\n`);
}, 10_000);
req.on('close', () => clearInterval(interval));
Virtualise Long Tables
Rendering 10,000 rows in the DOM is slow on every machine and browser. Virtualise any table that could have more than a few hundred rows.
TanStack Virtual handles this well:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualTable({ rows }: { rows: Row[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{ transform: `translateY(${virtualRow.start}px)`, position: 'absolute' }}
>
<TableRow row={rows[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Role-Based Access in the Data Layer
Every dashboard has eventually needed RBAC. The mistake is implementing it only in the UI — hiding buttons or routes based on the user's role. A determined user can bypass that with browser dev tools.
RBAC must be in the API. Every endpoint checks the authenticated user's role before returning data.
// middleware/requireRole.ts
export function requireRole(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Route: only admins and managers can see revenue data
router.get('/analytics/revenue', authenticate, requireRole('admin', 'manager'), getRevenue);
The UI checks roles too — to show or hide controls — but the API is the actual gate.
Chart Library Choices
Three libraries cover most dashboard needs:
- Recharts — declarative, composable, good default aesthetics, best for most projects
- Nivo — richer chart types (treemaps, chord diagrams, network), heavier bundle
- D3 — full control, steepest learning curve, right only when the other two cannot do what you need
Recharts is the right default. It accepts arrays of objects, maps well to TanStack Query results, and produces accessible SVG with proper ARIA labels.
One common mistake: importing the full library. Import only the components you use to keep the bundle small.
// Good: named imports
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
// Avoid: importing everything
// import * as Recharts from 'recharts';
Cache Chart Data Aggressively
Chart data is usually expensive to compute and rarely needs to be fresher than one minute. Cache aggressively at two levels:
- Server-side: Cache computed aggregations in Redis with a TTL matching your freshness requirement. A revenue chart cached for 60 seconds saves 59 database queries per minute per user.
- Client-side: TanStack Query's
staleTimeprevents unnecessary refetches when the user switches tabs and returns.
Building a custom React dashboard well is an architecture project as much as a UI project. If you need a dashboard built on these principles, see our custom dashboard development service or get in touch to discuss your requirements.
Need help with this? See our related service or get in touch.
Start a project →