What You'll Build
You will build a real-time metrics dashboard that exercises every hook from this module: useReducer to manage dashboard state, useEffect to subscribe to a live data feed with proper cleanup, useRef to track values across renders without re-rendering, useMemo to derive aggregates from incoming data, and a custom hook to encapsulate the live-data subscription. It is a realistic feature that ties the module together.
The dashboard receives a stream of metric updates (simulated with an interval), keeps a rolling window of recent readings in reducer state, computes aggregates like averages and peaks with useMemo, and cleans up its subscription on unmount. You will extract the subscription into a reusable custom hook, leaving the component clean and declarative.
Prerequisites
- Completion of lessons 06–09, or equivalent familiarity with useState/useReducer, useEffect, useRef/useMemo, and custom hooks.
- A React 19 + Vite project running locally (npm run dev).
- Comfort with reducers, effect cleanup, and the rules of hooks.
- Basic familiarity with setInterval/clearInterval for simulating a live feed.
- Understanding that custom hooks share logic, not state.
Setup & Project Structure
In your React project, create a custom hook useLiveMetrics that simulates a live data feed (an interval emitting random readings) and a Dashboard component that consumes it. You will manage the rolling window of readings with useReducer, derive aggregates with useMemo, and track the latest reading with useRef.
Structure the code so the subscription logic lives entirely in the custom hook, and the Dashboard component only declares state shape, dispatches updates, and renders. This separation is the core lesson: the component stays readable while the hook owns the live-data mechanics and cleanup.
// src/hooks/useLiveMetrics.js — custom hook owning the live feed + cleanup
import { useEffect } from "react";
export function useLiveMetrics(onReading, intervalMs = 1000) {
useEffect(() => {
const id = setInterval(() => {
onReading({ value: Math.round(Math.random() * 100), at: Date.now() });
}, intervalMs);
return () => clearInterval(id); // cleanup: stop the feed on unmount
}, [onReading, intervalMs]);
}
// src/Dashboard.jsx — consumes the hook; manages state with useReducer
// (built across the steps below)Step 1 — Model Dashboard State with useReducer
Define a reducer that maintains a rolling window of the last N readings. Actions include adding a reading (append and trim to N) and clearing. This centralises the state-transition logic — appending, trimming the window, resetting — in one pure, testable function rather than scattered setters.
Initialise the reducer with an empty readings array and a window size. The component dispatches an 'add' action for each incoming reading; the reducer immutably builds the new array and trims it. Using a reducer here pays off because each update is a coordinated transition (append plus trim), exactly the case useReducer suits.
import { useReducer } from "react";
const WINDOW = 20;
function metricsReducer(state, action) {
switch (action.type) {
case "add":
return { ...state,
readings: [...state.readings, action.reading].slice(-WINDOW) }; // trim window
case "clear":
return { ...state, readings: [] };
default:
return state;
}
}
// in component: const [state, dispatch] = useReducer(metricsReducer, { readings: [] });Step 2 — Wire the Custom Hook and useRef
Consume useLiveMetrics in the Dashboard, passing a stable callback (wrapped in useCallback) that dispatches an 'add' action for each reading. Use useRef to track the latest reading and a count of total readings received — values you want to persist across renders without triggering extra re-renders.
The ref demonstrates the distinction from state: the total-readings counter increments on every feed tick but does not itself need to re-render the UI on each change, so a ref is appropriate for an internal tally, while the rolling window (which is displayed) lives in reducer state. Pass a stable callback so the effect inside the hook does not re-subscribe each render.
import { useCallback, useRef } from "react";
function Dashboard() {
const [state, dispatch] = useReducer(metricsReducer, { readings: [] });
const totalCount = useRef(0); // persists, no re-render
const lastReading = useRef(null);
const handleReading = useCallback((reading) => { // stable identity for the hook's effect
totalCount.current += 1; // private tally (ref)
lastReading.current = reading;
dispatch({ type: "add", reading }); // displayed window (state)
}, []);
useLiveMetrics(handleReading, 1000); // custom hook owns subscription+cleanup
// ... render below
}Step 3 — Derive Aggregates with useMemo
From the rolling window of readings, compute aggregates — average, minimum, maximum, and a simple trend — with useMemo so they recompute only when the readings change, not on every unrelated render. These are derived values, correctly computed from state rather than stored as additional state, exactly as the effects lesson advised.
Render the dashboard: the live aggregates, the current window of readings, and the private ref tallies where useful. Because the aggregates are memoised over the readings array, they recompute precisely when new data arrives and are skipped otherwise — a genuine use of useMemo where the derivation runs frequently over a changing list.
import { useMemo } from "react";
// inside Dashboard, after the hooks above:
const stats = useMemo(() => {
const r = state.readings;
if (r.length === 0) return { avg: 0, min: 0, max: 0 };
const values = r.map(x => x.value);
return {
avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
min: Math.min(...values),
max: Math.max(...values),
};
}, [state.readings]); // recompute only when readings change
return (
<section>
<h2>Live Metrics</h2>
<p>Avg {stats.avg} · Min {stats.min} · Max {stats.max}</p>
<p>Total received: {totalCount.current} · Window: {state.readings.length}</p>
<button onClick={() => dispatch({ type: "clear" })}>Clear</button>
</section>
);Step 4 — Testing & Verification
Run the dashboard and confirm the full loop: readings arrive every second, the rolling window stays capped at the window size, the aggregates update as data flows, and the private ref tally counts every reading. Then unmount the Dashboard (e.g. toggle it off) and confirm via console logging that the interval is cleared — proving the custom hook's cleanup works and no feed leaks.
// Toggle the dashboard to verify cleanup on unmount
function App() {
const [show, setShow] = useState(true);
return (
<>
<button onClick={() => setShow(s => !s)}>{show ? "Hide" : "Show"}</button>
{show && <Dashboard />}
</>
);
}
// Add a console.log("feed stopped") in the hook's cleanup (return () => {...})
// and confirm it logs when you hide the Dashboard — no orphaned interval.Warning: If you pass an inline arrow function to useLiveMetrics instead of a stable useCallback'd one, the effect's dependency changes every render, so it clears and recreates the interval on every render — causing missed ticks or runaway timers. Stabilise the callback with useCallback (empty deps here, since it only uses refs and dispatch, which are stable), so the subscription is set up once and cleaned up once.
Extension Challenge: Replace the simulated interval with a real WebSocket in useLiveMetrics, keeping the same cleanup contract (close the socket on unmount). Then add a useDebounce custom hook to throttle a search/filter input over the readings, and a useLocalStorage hook to persist the window size across reloads — composing several custom hooks in one dashboard to see how cleanly logic separates from rendering.
- useReducer manages the rolling-window state with one pure transition (append + trim).
- A custom hook (useLiveMetrics) encapsulates the subscription and its cleanup, keeping the component clean.
- useRef tracks private tallies (total count, last reading) that persist without triggering re-renders.
- useMemo derives aggregates from the readings, recomputing only when readings change.
- Pass a stable (useCallback'd) callback to the hook so the effect doesn't re-subscribe every render.
- Effect cleanup stops the feed on unmount — verify no orphaned interval/socket leaks.