Building Real-Time IoT Dashboards with React and Recharts
React dashboards that poll REST APIs every second are the wrong approach for IoT. You get stale data, unnecessary server load, and poor UX. The right stack is WebSocket for real-time push, Recharts for visualization, and careful state management to prevent re-render hell.
This guide builds a production-grade IoT dashboard that handles 50+ devices updating every second without the UI becoming a slideshow.
Architecture
IoT Devices → MQTT Broker → Node.js WebSocket Server
↓
React Dashboard
├── useWebSocket hook
├── useReducer (telemetry store)
└── Recharts (charts)
Step 1: WebSocket Hook
// hooks/useWebSocket.ts
import { useEffect, useRef, useCallback } from 'react'interface WebSocketHookOptions {
url: string
onMessage: (data: unknown) => void
onConnect?: () => void
onDisconnect?: () => void
}
export function useWebSocket({ url, onMessage, onConnect, onDisconnect }: WebSocketHookOptions) {
const ws = useRef(null)
const reconnectTimer = useRef>()
const attempts = useRef(0)
const connect = useCallback(() => {
ws.current = new WebSocket(url)
ws.current.onopen = () => {
attempts.current = 0
onConnect?.()
}
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
onMessage(data)
} catch {}
}
ws.current.onclose = () => {
onDisconnect?.()
const delay = Math.min(30000, 1000 * 2 ** attempts.current)
attempts.current++
reconnectTimer.current = setTimeout(connect, delay)
}
ws.current.onerror = () => ws.current?.close()
}, [url, onMessage, onConnect, onDisconnect])
useEffect(() => {
connect()
return () => {
clearTimeout(reconnectTimer.current)
ws.current?.close()
}
}, [connect])
const send = useCallback((data: unknown) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(data))
}
}, [])
return { send }
}
Step 2: Telemetry State with useReducer
Never use useState for accumulating IoT data — useReducer gives you predictable updates and prevents stale closures:
// store/telemetry.ts
export interface DataPoint {
ts: number
temperature: number
humidity: number
voltage: number
}export interface DeviceTelemetry {
deviceId: string
label: string
isOnline: boolean
history: DataPoint[] // Rolling 60-point window
latest: DataPoint | null
}
type Action =
| { type: 'UPDATE_DEVICE'; deviceId: string; point: DataPoint }
| { type: 'SET_OFFLINE'; deviceId: string }
| { type: 'INIT_DEVICES'; devices: { deviceId: string; label: string }[] }
const HISTORY_WINDOW = 60 // Last 60 data points per device
export function telemetryReducer(
state: Record,
action: Action,
): Record {
switch (action.type) {
case 'UPDATE_DEVICE': {
const existing = state[action.deviceId]
if (!existing) return state
const newHistory = [...existing.history, action.point].slice(-HISTORY_WINDOW)
return {
...state,
[action.deviceId]: {
...existing,
isOnline: true,
latest: action.point,
history: newHistory,
},
}
}
case 'SET_OFFLINE':
return {
...state,
[action.deviceId]: { ...state[action.deviceId], isOnline: false },
}
case 'INIT_DEVICES': {
const init: Record = {}
for (const d of action.devices) {
init[d.deviceId] = { ...d, isOnline: false, history: [], latest: null }
}
return init
}
}
}
Step 3: Dashboard Component
// Dashboard.tsx
import { useReducer, useCallback } from 'react'
import { telemetryReducer } from './store/telemetry'
import { useWebSocket } from './hooks/useWebSocket'
import { DevicePanel } from './DevicePanel'export function Dashboard() {
const [devices, dispatch] = useReducer(telemetryReducer, {})
const handleMessage = useCallback((data: unknown) => {
const msg = data as { type: string; deviceId: string; payload: any }
if (msg.type === 'telemetry') {
dispatch({
type: 'UPDATE_DEVICE',
deviceId: msg.deviceId,
point: {
ts: Date.now(),
temperature: msg.payload.temperature,
humidity: msg.payload.humidity,
voltage: msg.payload.voltage,
},
})
}
if (msg.type === 'device_offline') {
dispatch({ type: 'SET_OFFLINE', deviceId: msg.deviceId })
}
}, [])
useWebSocket({
url: ${process.env.REACT_APP_WS_URL}/dashboard,
onMessage: handleMessage,
})
return (
{Object.values(devices).map(device => (
))}
)
}
Step 4: Recharts Live Chart
// DevicePanel.tsx
import { memo } from 'react'
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer
} from 'recharts'
import type { DeviceTelemetry } from './store/telemetry'interface Props {
device: DeviceTelemetry
}
// memo() prevents re-render when OTHER devices update
export const DevicePanel = memo(function DevicePanel({ device }: Props) {
const chartData = device.history.map((pt, i) => ({
index: i,
temperature: pt.temperature,
humidity: pt.humidity,
ts: new Date(pt.ts).toLocaleTimeString(),
}))
return (
{device.label}
{device.isOnline ? '● Online' : '○ Offline'}
{device.latest && (
${device.latest.temperature.toFixed(1)}°C} color="text-orange-400" />
${device.latest.humidity.toFixed(0)}%} color="text-blue-400" />
${device.latest.voltage.toFixed(2)}V} color="text-green-400" />
)}
)
})const Stat = ({ label, value, color }: { label: string; value: string; color: string }) => (
text-lg font-mono font-bold ${color}}>{value}
{label}
)
Critical performance notes:
isAnimationActive={false} — Recharts animations on real-time data cause jankmemo() — each DevicePanel only re-renders when its own device data changesuseCallback on handleMessage — prevents WebSocket hook from reconnecting on every renderStep 5: Performance Optimizations
For 50+ devices updating every second:
// Throttle UI updates — don't re-render more than 2×/second per device
import { throttle } from 'lodash'const handleMessage = useCallback(
throttle((data: unknown) => {
// dispatch updates
}, 500), // 500ms = max 2 renders/sec
[],
)
// Virtualize the device grid for large fleets
import { FixedSizeGrid } from 'react-window'
// For 100+ devices, use react-window instead of a flat grid
Need a production IoT dashboard built for your fleet? [Contact Code Caracal](/contact) — we've built dashboards monitoring thousands of real-time devices.