Rreact.wiki
← Blog

Why Your React Dashboard Lags at 50Hz (And What to Do Instead)

Why Your React Dashboard Lags at 50Hz (And What to Do Instead)

Learn why high-frequency telemetry (like drone attitude at 50Hz) crashes React’s render cycle—and how to keep your dashboard smooth by moving state updates *outside* the React reconciliation path.

The “Smooth” Dashboard That Isn’t

You’ve built a sleek React dashboard for monitoring a drone—or a robot, sensor array, or industrial PLC. You wire up WebSocket messages, call setState for each new reading, and watch as your gauges, maps, and flight displays start… stuttering.

At first glance, it looks fine in dev mode. Then you plug in real data: attitude updates at 50Hz, GPS at 10Hz, battery voltage every 200ms. Suddenly, your PFD lags behind the map. The altitude tape jumps while the vertical speed needle freezes. Your event log scrolls after the aircraft has already drifted off course.

This isn’t a bug in your code. It’s React doing exactly what it’s designed to do—reconcile changes on every state update—and hitting a hard wall: the browser’s 60fps render budget.

Let’s walk through why this happens—and how to fix it without rewriting your app in vanilla JS.

Why 50Hz Breaks React (Spoiler: It’s Not the FPS)

React doesn’t render on demand. It batches state updates and schedules renders during the browser’s next animation frame (~16ms). If you call setState 50 times per second (i.e., every ~20ms), React tries to reconcile all those updates within one frame. Worse: each update triggers re-renders across every component subscribed to that state—even if only one number changed.

So with five telemetry channels (roll, pitch, yaw, altitude, airspeed), updating at 50Hz, you’re forcing React to process 250 state changes per second, most of which are irrelevant to any given component. The result? Dropped frames, inconsistent timestamps across instruments, and UI that feels “rubbery” or unresponsive.

💡 Key insight: React is optimized for user-initiated interactions (clicks, form input), not machine-generated firehose data. Trying to force telemetry through useState is like routing a firehose through a soda straw.

Step 1: Build the Layout First (No Drone Required)

Before touching real data, build your dashboard against mock motion. This isolates layout, styling, and timing issues from data plumbing.

Install the essentials:

Bash
npm install @altara/core @altara/aerospace
npm install leaflet@^1.9.4 react-leaflet@^4.2.1

Now create a FlightDeck.tsx with two animated panels—no state, no effects, no providers:

TSX
import { PrimaryFlightDisplay } from '@altara/aerospace';
import { LiveMap } from '@altara/core';
 
export function FlightDeck() {
  return (
    <div style={{ display: 'flex', gap: 16 }}>
      <PrimaryFlightDisplay mockMode size="md" showFlightDirector />
      <LiveMap mockMode />
    </div>
  );
}

That’s it. The mockMode prop activates built-in motion generators—not random noise, but physically plausible behavior:

  • The PFD simulates gentle coordinated turns (bank + pitch out of phase).
  • The map traces a slow circle, rotating to match heading.
  • All motion runs on requestAnimationFrame, outside React’s render loop.

Why does this matter? Because it proves your UI can animate smoothly before you add data. If it stutters here, the issue is CSS layout or paint performance—not telemetry.

Step 2: Assemble the Full Dashboard (Still Fake)

Now expand to four panels in a grid. Notice how mockMode works differently per component:

TSX
import { LiveMap, Gauge, EventLog, type EventLogEntry } from '@altara/core';
import { PrimaryFlightDisplay } from '@altara/aerospace';
 
const demoEvents: EventLogEntry[] = [
  { timestamp: Date.now() - 9000, severity: 'info', message: 'EKF using GPS' },
  { timestamp: Date.now() - 6000, severity: 'info', message: 'Armed' },
  { timestamp: Date.now() - 3000, severity: 'warn', message: 'Wind 11 m/s, approaching limit' },
  { timestamp: Date.now() - 1000, severity: 'info', message: 'Waypoint 3 reached' },
];
 
export function GroundControlStation() {
  return (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: '1fr 1fr',
        gap: 16,
        padding: 16,
        alignItems: 'start',
      }}
    >
      <PrimaryFlightDisplay mockMode size="md" showFlightDirector />
      <LiveMap mockMode />
      <Gauge
        mockMode
        mockProfile="ramp"
        min={0}
        max={100}
        label="Battery"
        unit="%"
        thresholds={[
          { value: 0, color: 'var(--vt-color-danger)' },
          { value: 20, color: 'var(--vt-color-warn)' },
          { value: 40, color: 'var(--vt-color-active)' },
        ]}
      />
      <EventLog entries={demoEvents} />
    </div>
  );
}

Two details stand out:

  • Gauge uses mockProfile="ramp" instead of the default sine wave—because batteries drain, they don’t oscillate. Mock data should reflect physics, not convenience.
  • EventLog takes static entries, not mockMode. Why? Text logs aren’t scalar values—they’re discrete events. Forcing them through a mock generator would obscure their real shape (timestamp, severity, message). So you own that array explicitly.

This approach lets you finalize spacing, colors, thresholds, and responsive behavior before connecting hardware. The hardest part of a dashboard isn’t parsing MAVLink—it’s making sure the PFD fits beside the map without overflow.

Step 3: Replace Mocks With Real Data (Without Rewriting)

Now swap mockMode for live data—but keep exactly the same component tree. The secret? Decouple data ingestion from rendering.

Altara components accept a dataSource prop—a simple object with a subscribe() method that emits { timestamp, value } objects. No React state. No useEffect spaghetti.

Here’s how to wire MAVROS over rosbridge:

TSX
import { useEffect, useMemo, useState } from 'react';
import { EventLog, Gauge, LiveMap } from '@altara/core';
import { PrimaryFlightDisplay } from '@altara/aerospace';
import {
  createBatteryStateAdapter,
  createImuAdapter,
  createRosbridgeAdapter,
  mergeChannels,
} from '@altara/ros';
 
export function LiveGroundControlStation() {
  const [batterySource, setBatterySource] = useState<any>(null);
  const [imuSource, setImuSource] = useState<any>(null);
  const [logEntries, setLogEntries] = useState<EventLogEntry[]>([]);
 
  // Connect to rosbridge once
  useEffect(() => {
    const adapter = createRosbridgeAdapter('ws://localhost:9090');
    
    // Convert ROS /battery topic → battery % (0–100)
    const battery$ = adapter.topic('/battery', createBatteryStateAdapter());
    setBatterySource(battery$);
 
    // Convert ROS /mavros/imu/data → roll/pitch/yaw/altitude/airspeed
    const imu$ = adapter.topic('/mavros/imu/data', createImuAdapter());
    setImuSource(imu$);
 
    // Subscribe to log stream
    adapter.topic('/mavros/statustext/recv', (msg) => {
      setLogEntries(prev => [
        ...prev.slice(-9), // Keep last 10
        {
          timestamp: Date.now(),
          severity: msg.severity === 2 ? 'warn' : 'info',
          message: msg.text,
        }
      ]);
    });
  }, []);
 
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, padding: 16 }}>
      <PrimaryFlightDisplay dataSource={imuSource} size="md" showFlightDirector />
      <LiveMap dataSource={imuSource} />
      <Gauge dataSource={batterySource} label="Battery" unit="%" /* ... */ />
      <EventLog entries={logEntries} />
    </div>
  );
}

Notice what’s not happening:

  • No useState for roll, pitch, yaw, etc.
  • No useEffect per channel.
  • No manual requestAnimationFrame or setTimeout throttling.

The dataSource abstraction moves telemetry handling outside React’s render cycle. Updates flow directly into canvas/WebGL layers (PFD), Leaflet (map), or SVG (gauge)—bypassing reconciliation entirely.

The Shift: From “React State” to “Data Pipeline”

You’re no longer asking React to render 50Hz data. You’re asking it to orchestrate a pipeline:

  • createRosbridgeAdapter connects to WebSocket.
  • Adapters transform ROS messages into normalized telemetry streams.
  • Components subscribe and draw—using requestAnimationFrame, canvas, or Leaflet’s native rendering.

This is the architectural shift: React manages what to show, not how often to update. The heavy lifting happens in dedicated, optimized rendering paths—not in component render() functions.

Start with mock mode. Lock down layout. Then swap in dataSource. Your dashboard stays snappy—not because you optimized useMemo, but because you stopped fighting React’s grain.

And when your drone finally takes off? Your dashboard won’t lag. It’ll just… keep up.