How to Build an Analytics Dashboard with React
Build an analytics dashboard with React 19, Vite and Recharts: a data-fetching hook, date filters, and loading and error states you can copy and run today.
You shipped the stats endpoints, the numbers are correct in psql, and now someone wants to click around them. A static page was fine for three KPI cards. Once you add a date picker, a second chart and a refresh button, the plain-JavaScript version turns into a pile of DOM updates that nobody wants to touch. That is the moment an analytics dashboard with React earns its place.
The hard part is not drawing a chart. The hard part is data fetching: stale responses overwriting fresh ones, spinners that never stop, and filters that fall out of sync with what the chart shows. React gives you a clean model for this, but only if you treat server data as something that arrives late, fails sometimes and can be empty.
In this article you build a small dashboard for Acme Shop with Vite, React 19 and Recharts. You write one reusable data-fetching hook, a filter bar, a line chart for page views and a bar chart for the purchase funnel. Every panel handles loading, error and empty states properly.
This article assumes you already have the stats API from the Express analytics API article. If you want the no-build alternative, read the static HTML and JavaScript dashboard first. I do not redefine KPI formulas here, because that article owns them.
What the Analytics Dashboard with React Owns
The dashboard does presentation and interaction. It does not compute metrics. Sessionization, funnel logic and retention math stay in SQL behind the API, which you built earlier. If you find yourself grouping raw events in a React component, move that logic back to the server.
This split matters for a practical reason. A browser should never download ten thousand raw events to count them. Aggregate rows are small, cacheable and identical for every viewer, so the API returns aggregates and React draws them.
The contract below matches the endpoints built in the analytics API article, trimmed to the fields this dashboard uses. Dates are inclusive UTC calendar days in YYYY-MM-DD form, and the numbers are illustrative. Both endpoints require the bearer token from that article, and they return 400 for an invalid range and 401 for a missing token.
GET /v1/stats/pageviews?from=2025-09-01&to=2025-09-30
{
"from": "2025-09-01",
"to": "2025-09-30",
"totals": { "pageviews": 14210, "visitors": 5230, "sessions": 6890 },
"daily": [
{ "date": "2025-09-01", "pageviews": 1204, "visitors": 611 },
{ "date": "2025-09-02", "pageviews": 1377, "visitors": 702 }
]
}
GET /v1/stats/funnel?from=2025-09-01&to=2025-09-30
{
"from": "2025-09-01",
"to": "2025-09-30",
"steps": [
{ "event_name": "page_view", "visitors": 5230 },
{ "event_name": "add_to_cart", "visitors": 610 },
{ "event_name": "purchase_completed", "visitors": 140 }
]
}
Both endpoints use the canonical Acme Shop event names. The PostgreSQL tables behind them are defined in the schema design article.
Project Setup with Vite and a Dev Proxy
Vite gives you a fast dev server and a production build with almost no configuration. At the time of writing, it requires Node.js 20.19 or newer (or 22.12 or newer), so check the Vite guide for the current minimum before you install. Scaffold the project and add Recharts.
npm create vite@latest acme-dashboard -- --template react
cd acme-dashboard
npm install
npm install recharts
Your collector enforces a CORS allow-list, which is correct. In development, however, the dashboard runs on localhost:5173 and the API on port 4000. Instead of adding localhost to the production allow-list, proxy API calls through Vite. The browser then sees one origin and CORS never enters the picture.
The proxy solves a second problem. The stats endpoints need the bearer token, and a token placed in browser code is public. So the proxy adds the Authorization header on the server side, reading STATS_TOKEN from your environment. Because the name has no VITE_ prefix, Vite never copies it into the bundle.
Start the dev server with STATS_TOKEN=your-token npm run dev.
// vite.config.js
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
// The empty prefix loads STATS_TOKEN too. It stays in this Node process
// and never reaches the browser bundle, because it has no VITE_ prefix.
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [react()],
server: {
proxy: {
'/v1': {
target: 'http://localhost:4000',
headers: { Authorization: `Bearer ${env.STATS_TOKEN}` },
},
},
},
};
});
In production, serve the built files from the same host as the API and let your reverse proxy attach the token, or replace the shared token with real user sessions. Never ship the token in the bundle, and never use a wildcard origin on an endpoint that returns business metrics. The cost of this setup is that anyone who can open the dashboard can read every metric, so put it behind your login or VPN.
A Data-Fetching Hook That Survives Fast Filter Changes
Most dashboard bugs live in the fetching code, not the charts. Here is the version most developers write first.
// WRONG: no cleanup, no error state
function usePageviews(from, to) {
const [rows, setRows] = useState([]);
useEffect(() => {
fetch(`/v1/stats/pageviews?from=${from}&to=${to}`)
.then((r) => r.json())
.then((json) => setRows(json.daily));
}, [from, to]);
return rows;
}
A mistake I have seen in production is exactly this hook behind a date picker. A user switched from “last 30 days” to “last 7 days” quickly. The 30-day request was slower, so it resolved last and overwrote the 7-day data. The chart title said seven days, the numbers described thirty, and nobody noticed for days because the shape looked plausible.
The fix is to cancel the previous request when the inputs change. The React documentation on useEffect recommends a cleanup function for this reason, and an AbortController goes one step further by stopping the network request too.
// src/useStats.js
import { useEffect, useState } from 'react';
export function useStats(path, params, refreshKey = 0) {
const [state, setState] = useState({ status: 'loading', data: null, error: null });
const query = new URLSearchParams(params).toString();
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading', data: null, error: null });
fetch(`${path}?${query}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
return res.json();
})
.then((data) => setState({ status: 'success', data, error: null }))
.catch((error) => {
if (error.name === 'AbortError') return;
setState({ status: 'error', data: null, error });
});
return () => controller.abort();
}, [path, query, refreshKey]);
return state;
}
Four details matter here. First, the hook checks res.ok, because fetch only rejects on network failure, not on a 500. Second, it ignores AbortError, which is a normal outcome and not a failure.
Third, the effect depends on the serialized query string, not on the params object. An object literal is a new reference on every render and would trigger an infinite refetch loop. Fourth, the optional refreshKey argument lets a parent force a refetch by changing a number, which the refresh section uses later.
In development, React’s Strict Mode runs every effect twice to expose missing cleanup. You will therefore see each request fire twice, with the first one cancelled. That is the cleanup working, not a bug, and it does not happen in the production build.
The trade-off is that this hook has no cache. Switching tabs and returning refetches everything. For a dashboard with two panels, that cost is fine. I cover when to change that in the comparison table below.
Filters as State, Not as DOM
The date range is the one piece of state every panel shares, so it lives in the parent component. Panels receive it as props. This keeps a single source of truth, which is what prevents the “chart says one thing, picker says another” bug.
Use plain date strings in YYYY-MM-DD form as state, not Date objects. Date objects carry a time zone, and a user in Mumbai and a server in UTC will disagree about which day an event belongs to. Your events use UTC timestamps, so the dashboard should say “UTC days” in its labels and send strings the API can use without conversion.
// src/FilterBar.jsx
export function FilterBar({ from, to, onChange }) {
return (
<form onSubmit={(e) => e.preventDefault()}>
<label>
From (UTC)
<input type="date" value={from} max={to}
onChange={(e) => e.target.value && onChange({ from: e.target.value, to })} />
</label>
<label>
To (UTC)
<input type="date" value={to} min={from}
onChange={(e) => e.target.value && onChange({ from, to: e.target.value })} />
</label>
</form>
);
}
The min and max attributes stop a user from picking an inverted range, and the handlers ignore an empty value, which a browser reports when someone clears the field. The server still validates, as the stats API does with its 400 response, because the browser is not a trust boundary.
For shareable links, mirror the range into the URL query string. That lets a teammate open exactly the view you saw. It is a small addition with a large payoff in real use, since dashboards mostly get shared in chat.
Charting with Recharts
You could hand-roll SVG, but axes, tooltips and resizing take far longer than they look. Recharts is a React-first charting library built on SVG components, so a chart is just JSX. Version 3 lists React 19 among its supported peer versions, and the project documentation now lives at recharts.github.io.
A line chart suits page views over time because the x-axis is ordered and continuous. A horizontal bar chart suits funnel steps because you compare discrete stages.
// src/PageviewsChart.jsx
import {
LineChart, Line, XAxis, YAxis, Tooltip, Legend, CartesianGrid, ResponsiveContainer,
} from 'recharts';
export function PageviewsChart({ rows }) {
return (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={rows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="pageviews" stroke="#2563eb" dot={false} />
<Line type="monotone" dataKey="visitors" stroke="#d97706" dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
Two pitfalls show up here repeatedly. First, ResponsiveContainer measures its parent, and its height defaults to 100 percent of that parent. A parent with no explicit height therefore renders an empty chart with no error. The numeric height above avoids that.
Second, Recharts gives every Line the same default color, so the two series above would be indistinguishable without the explicit stroke values and the Legend.
A missing day in an API response would not appear as zero either. The line would skip from the 3rd to the 5th. The stats API from the earlier article already zero-fills every day in the range, which is the right place for it. If you consume an API that does not, fill gaps with generate_series in SQL, not in the component, so every consumer gets the same answer.
// src/FunnelChart.jsx
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
export function FunnelChart({ steps }) {
return (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={steps} layout="vertical">
<XAxis type="number" allowDecimals={false} />
<YAxis type="category" dataKey="event_name" width={150} />
<Tooltip />
<Bar dataKey="visitors" fill="#2563eb" />
</BarChart>
</ResponsiveContainer>
);
}
Use the raw event_name values as labels. If you rename them to friendly text, keep a lookup in one file, so “purchase_completed” never appears as three different strings across the interface.
Loading, Error and Empty States
Every panel has four states, and a dashboard that ignores three of them looks broken in production. A blank rectangle could mean “loading,” “the API is down” or “no traffic yesterday.” Those are very different messages for the reader.
Wrap the pattern once in a Panel component. Then each chart only needs to describe its data.
// src/Panel.jsx
export function Panel({ title, state, isEmpty, children }) {
let body;
if (state.status === 'loading') {
body = <p role="status">Loading...</p>;
} else if (state.status === 'error') {
body = <p role="alert">Could not load this panel: {state.error.message}</p>;
} else if (isEmpty(state.data)) {
body = <p>No events in this date range.</p>;
} else {
body = children(state.data);
}
return (
<section>
<h2>{title}</h2>
{body}
</section>
);
}
The role attributes tell screen readers when the status changes. Also, an error in one panel no longer takes down the other. The funnel can fail while the page view chart keeps working, which is exactly what you want when one endpoint has a slow query.
Resist showing a zero for an error. A zero looks like a measurement. A failed request is the absence of one, and the interface should say so.
Putting It Together
Here is the complete application component, with a refresh button added. Together with main.jsx from the Vite template, the files above give you a working dashboard.
// src/App.jsx
import { useState } from 'react';
import { useStats } from './useStats.js';
import { FilterBar } from './FilterBar.jsx';
import { Panel } from './Panel.jsx';
import { PageviewsChart } from './PageviewsChart.jsx';
import { FunnelChart } from './FunnelChart.jsx';
function isoDay(offsetDays) {
const d = new Date();
d.setUTCDate(d.getUTCDate() + offsetDays);
return d.toISOString().slice(0, 10);
}
export default function App() {
const [range, setRange] = useState({ from: isoDay(-29), to: isoDay(0) });
const [refreshKey, setRefreshKey] = useState(0);
const pageviews = useStats('/v1/stats/pageviews', range, refreshKey);
const funnel = useStats('/v1/stats/funnel', range, refreshKey);
return (
<main>
<h1>Acme Shop analytics</h1>
<FilterBar from={range.from} to={range.to} onChange={setRange} />
<button type="button" onClick={() => setRefreshKey((k) => k + 1)}>Refresh</button>
<Panel title="Page views per day (UTC)" state={pageviews}
isEmpty={(d) => d.daily.length === 0}>
{(d) => <PageviewsChart rows={d.daily} />}
</Panel>
<Panel title="Purchase funnel" state={funnel}
isEmpty={(d) => d.steps.length === 0}>
{(d) => <FunnelChart steps={d.steps} />}
</Panel>
</main>
);
}
Start your API, then run STATS_TOKEN=your-token npm run dev and open the printed local address. Change the range and watch each panel show its loading state independently. Then stop your API and confirm both panels show an error instead of hanging.
I ran this exact set of files against Vite, React 19 and Recharts 3 while preparing the article. The production build succeeds, the proxy attached the token, and a test that resolved a 7-day response before a cancelled 30-day response kept the 7-day data.
Refresh, Polling and Cost
Dashboards tempt you to auto-refresh every few seconds. Resist it by default. Each refresh runs your aggregate queries again, and ten open tabs multiply that load by ten. Daily numbers rarely change fast enough to justify it.
If you need freshness, start with the manual refresh button from the code above. It increments a refreshKey counter that sits in the hook’s dependency list. For a “today” view, you can later increment that counter from a timer every 30 to 60 seconds, and pause the timer when document.hidden is true. This article does not build the timer.
A related production lesson: the stats API should be cheap before the dashboard is allowed to call it often. If a funnel query scans the whole events table, the fix belongs in the database, not in a longer polling interval. That is the territory of rollup tables, which later articles cover. For the bigger architectural question of how events reach those tables, see event-driven analytics architecture.
Hand-Written Hook or a Data-Fetching Library
The hook above handles two panels well. However, libraries such as TanStack Query and SWR exist because fetching logic grows. Compare them honestly before you decide.
| Concern | Custom useStats hook | TanStack Query or SWR |
|---|---|---|
| Dependencies | None beyond React | One extra package |
| Cancel stale requests | You write it (done above) | Built in |
| Caching between views | None | Built in, configurable |
| Deduplicate identical requests | No | Yes |
| Retry and refetch on focus | You write it | Built in |
| Best for | 1 to 4 panels, learning, small bundle | Many panels, shared data, frequent navigation |
My rule: start with the hook, and switch when you catch yourself adding caching or retry logic by hand. Writing it once teaches you what the library is doing for you.
How Real Systems Do This
Open-source analytics tools show the same split. The public repositories of Plausible and PostHog both list React in their front-end dependencies, and neither browser app reads raw events directly. Matomo is a PHP application that computes its reports on the server. Whatever the front-end technology, the pattern matches what you just built: the server computes, the client displays.
Commercial tools add things you should not underestimate. Saved views, permissions, scheduled email reports and embedded sharing are product work, not charting work. Treat that as honest input to the build-versus-buy decision, which I return to below.
Decision Framework
- Do you need interactive filters, several linked panels or role-based views? If not, the static page from the earlier article is cheaper to maintain.
- Does your API already return aggregates? If not, fix that first, because React cannot make a slow query fast.
- How many panels share the same data? One to four means the custom hook. More means a fetching library.
- Who views the dashboard? An internal team can tolerate a plain look. Customers expect polish, export and access control.
- Where will you host it? Same origin as the API avoids CORS work. A different origin needs an explicit allow-list entry.
When NOT to Use This
- You need three numbers. A single HTML page with a script tag loads faster and has no build step. React adds a toolchain you must maintain.
- You need ad hoc exploration. If analysts ask a new question daily, a BI tool or notebook beats a custom dashboard. Building a query builder in React is a project, not a feature.
- You need customer-facing, permissioned reporting soon. Authentication, row-level access and exports take longer than the charts. Buying a hosted tool is often the honest answer.
Common Mistakes
- Skipping request cancellation, so a slow old response overwrites a newer one and the chart shows the wrong range.
- Putting an object or array literal in the
useEffectdependency list, which refetches on every render and hammers your API. - Treating
fetchas failed only on network errors, so a 500 response renders as an empty chart. - Using local-time
Dateobjects for filters, which shifts events by a day for users far from UTC. - Aggregating raw events in the browser, which makes pages slow and numbers differ from the SQL reports.
- Showing zero for a failed or missing value, which readers take as a real measurement.
Key Takeaways
- Keep the dashboard a view layer: the API aggregates and React draws.
- Write one
useStatshook with AbortController cleanup and a serialized query dependency. - Store filters as UTC date strings in the parent component and pass them down.
- Render every panel in four states: loading, error, empty and data.
- Give
ResponsiveContainera numeric height, and fill date gaps on the server. - Proxy API calls through Vite in development, and attach the stats token there, never in browser code.
- Adopt a fetching library only when you hand-write caching or retries.
FAQ
How do I fetch data in a React dashboard?
Use useEffect with a cleanup function, or a custom hook that wraps it. Create an AbortController, pass its signal to fetch, and abort in the cleanup. Always check res.ok and keep loading, error and data in one state object.
Which charting library should I use for a React analytics dashboard?
Recharts is a solid default for line, bar and area charts because charts are plain JSX components. If you need very large datasets or custom graphics, consider a canvas-based option. For a first dashboard, Recharts is enough.
Should I use Redux for dashboard state?
No. A date range and server data do not need a global store. Local state in a parent component handles filters, and a hook or fetching library handles server data.
Why does my Recharts chart render empty?
The most common cause is a parent element with no explicit height around a ResponsiveContainer that uses a percentage height. The second most common is a dataKey that does not match the field names in your API response.
How do I avoid CORS errors in development?
Configure a proxy in vite.config.js so the browser calls the dev server and Vite forwards the request to your API. This keeps your production CORS allow-list strict.
Conclusion
An analytics dashboard with React is mostly an exercise in honest data fetching. Cancel stale requests, show every state, keep dates in UTC and let the server do the math. The charts take an afternoon, and the correctness details are what make people trust the numbers.
Rule of thumb: if a panel can be wrong without anyone noticing, it is not finished.
Last updated on 9 October 2026.
