WellAlly Logo
WellAlly康心伴
Development

Building a Real-Time Heart Rate Dashboard with React & the Web Bluetooth API

A step-by-step tutorial on connecting a React web app to a Bluetooth heart rate monitor. Learn to handle BLE data, manage connection state with hooks, and display live data with Recharts.

W
2025-12-11
9 min read

The internet of things (IoT) is no longer a futuristic concept; it's a present-day reality. For web developers, this means a growing need to interact with hardware directly from the browser. Imagine building a web application that monitors your fitness stats in real time, controls smart home devices, or interacts with environmental sensors. This is all possible thanks to the Web Bluetooth API.

In this tutorial, we'll build a practical and exciting project: a real-time heart rate dashboard using React. We'll leverage the Web Bluetooth API to discover and connect to a nearby Bluetooth Low Energy (BLE) heart rate monitor. We'll then read the incoming data, parse it, and display it on a live-updating chart.

This project is a perfect entry point into the world of web-based IoT. You'll gain hands-on experience with a powerful browser API and learn how to structure a React application to handle real-world, real-time data streams.

Prerequisites:

  • Solid understanding of React and React Hooks.
  • Node.js and npm (or yarn) installed.
  • A Bluetooth Low Energy (BLE) heart rate monitor. If you don't have one, you can use an emulator app like nRF Connect for Mobile.
  • A browser that supports the Web Bluetooth API (like Chrome or Opera).

Why this matters to developers: The Web Bluetooth API opens up a new frontier for web applications, allowing for direct interaction with the physical world without needing native apps. For health tech, fitness apps, and countless other IoT domains, this is a game-changer.

Understanding the Problem

Connecting a web app to a piece of hardware might sound complex, but the Web Bluetooth API simplifies it significantly. The core challenge lies in a few key areas:

  • Device Discovery and Connection: A user must initiate the connection for security reasons. Our app needs to provide a clear way to start a scan and select a device.
  • GATT Protocol: BLE devices communicate using the Generic Attribute Profile (GATT). We need to understand its structure of services and characteristics to get the data we want.
  • Binary Data Handling: The heart rate data isn't sent as a simple number. It's encoded in a binary format that we need to parse according to the Bluetooth specification.
  • Real-time State Management: The connection status and incoming data are asynchronous and dynamic. We need to manage this state effectively in React to ensure our UI is always in sync.

Our approach will tackle these challenges head-on with a clean, modern React implementation.

Prerequisites

Before we start coding, let's set up our development environment.

  1. Create a new React app:
    code
    npx create-react-app heart-rate-dashboard
    cd heart-rate-dashboard
    
    Code collapsed
  2. Install a charting library: We'll use recharts, a simple and composable charting library for React.
    code
    npm install recharts
    
    Code collapsed
  3. Get your Heart Rate Monitor Ready: Make sure your heart rate monitor is on and discoverable. If you're using a chest strap, you might need to be wearing it for it to start broadcasting.

Step 1: Connecting to the BLE Device

The first step is to create a mechanism for discovering and connecting to the heart rate monitor. The Web Bluetooth API requires a user action, like a button click, to initiate the device scan.

What we're doing

We'll create a custom hook, useBluetooth, to encapsulate the logic for connecting, disconnecting, and managing the device state. This keeps our component logic clean and the Bluetooth functionality reusable.

Implementation

Create a new file src/useBluetooth.js:

code
// src/useBluetooth.js
import { useState } from 'react';

const useBluetooth = () => {
  const [device, setDevice] = useState(null);
  const [server, setServer] = useState(null);
  const [error, setError] = useState(null);

  const connect = async () => {
    try {
      if (!navigator.bluetooth) {
        throw new Error('Web Bluetooth API is not available in this browser.');
      }

      console.log('Requesting Bluetooth device...');
      const bleDevice = await navigator.bluetooth.requestDevice({
        filters: [{ services: ['heart_rate'] }],
      });

      console.log('Connecting to GATT server...');
      const gattServer = await bleDevice.gatt.connect();

      setDevice(bleDevice);
      setServer(gattServer);
      setError(null);

      bleDevice.addEventListener('gattserverdisconnected', onDisconnected);

    } catch (err) {
      console.error(err);
      setError(err.message);
    }
  };

  const onDisconnected = () => {
    console.log('Device disconnected');
    setDevice(null);
    setServer(null);
  };

  const disconnect = () => {
    if (device && device.gatt.connected) {
      device.gatt.disconnect();
    }
  };

  return { device, server, error, connect, disconnect };
};

export default useBluetooth;
Code collapsed

Now, let's use this hook in our main App.js component.

code
// src/App.js
import React from 'react';
import useBluetooth from './useBluetooth';
import './App.css';

function App() {
  const { device, connect, disconnect } = useBluetooth();

  return (
    <div className="App">
      <header className="App-header">
        <h1>Heart Rate Dashboard</h1>
        {!device ? (
          <button onClick={connect}>Connect to Heart Rate Monitor</button>
        ) : (
          <button onClick={disconnect}>Disconnect</button>
        )}
        {device && <p>Connected to: {device.name}</p>}
      </header>
    </div>
  );
}

export default App;
Code collapsed

How it works

  • navigator.bluetooth.requestDevice: This is the entry point to the Web Bluetooth API. We provide it with a filter to only show devices that advertise the standard 'heart_rate' service (0x180D). This opens a browser prompt for the user to select a device.
  • device.gatt.connect(): Once a device is selected, we connect to its GATT server. The GATT server holds all the services and characteristics of the device.
  • State Management: We use useState to store the device and server objects. This allows our component to re-render when the connection status changes.
  • Event Listener: We add a gattserverdisconnected event listener to clean up our state when the device disconnects.

Common pitfalls

  • HTTPS Required: The Web Bluetooth API only works on secure contexts (HTTPS or localhost).
  • User Gesture: requestDevice() must be called inside an event handler for a user action, like a click. You can't trigger it programmatically on page load.
  • No Support: If navigator.bluetooth is undefined, the browser doesn't support the API. Our code includes a check for this.

Step 2: Reading and Parsing Heart Rate Data

Now that we're connected, it's time to get the data. We need to find the "Heart Rate Measurement" characteristic and subscribe to its notifications.

What we're doing

We'll extend our useBluetooth hook to handle starting notifications and parsing the incoming binary data. The heart rate value is encoded in a specific format that we'll need to decode.

Implementation

Let's add to src/useBluetooth.js:

code
// src/useBluetooth.js (updated)
import { useState, useCallback } from 'react';

// ... (previous code for connect, disconnect)

const useBluetooth = () => {
  const [device, setDevice] = useState(null);
  const [server, setServer] = useState(null);
  const [heartRate, setHeartRate] = useState(null);
  const [error, setError] = useState(null);

  const parseHeartRate = (value) => {
    const flags = value.getUint8(0);
    const rate16Bits = flags & 0x1;
    let heartRate;
    if (rate16Bits) {
      heartRate = value.getUint16(1, true);
    } else {
      heartRate = value.getUint8(1);
    }
    return heartRate;
  };

  const startNotifications = async () => {
    try {
      console.log('Getting Heart Rate Service...');
      const service = await server.getPrimaryService('heart_rate');

      console.log('Getting Heart Rate Measurement Characteristic...');
      const characteristic = await service.getCharacteristic('heart_rate_measurement');

      console.log('Starting notifications...');
      await characteristic.startNotifications();

      characteristic.addEventListener('characteristicvaluechanged', handleHeartRateChange);
    } catch (err) {
      console.error(err);
      setError(err.message);
    }
  };

  const handleHeartRateChange = useCallback((event) => {
    const value = event.target.value;
    const newHeartRate = parseHeartRate(value);
    setHeartRate(newHeartRate);
  }, []);

  // ... (connect, disconnect, onDisconnected functions)

  return { device, server, error, connect, disconnect, startNotifications, heartRate };
};

export default useBluetooth;

Code collapsed

We also need to call startNotifications in App.js once we are connected.

code
// src/App.js (updated)
import React, { useEffect } from 'react';
import useBluetooth from './useBluetooth';
import './App.css';

function App() {
  const { device, connect, disconnect, startNotifications, heartRate } = useBluetooth();

  useEffect(() => {
    if (device) {
      startNotifications();
    }
  }, [device, startNotifications]);


  return (
    <div className="App">
      <header className="App-header">
        <h1>Heart Rate Dashboard</h1>
        {/* ... buttons ... */}
        {device && <p>Connected to: {device.name}</p>}
        <div className="heart-rate-display">
          {heartRate ? `${heartRate} BPM` : '...'}
        </div>
      </header>
    </div>
  );
}

export default App;
Code collapsed

How it works

  • server.getPrimaryService('heart_rate'): We request the standard heart rate service.
  • service.getCharacteristic('heart_rate_measurement'): Within that service, we get the characteristic that provides the heart rate measurement (0x2A37).
  • characteristic.startNotifications(): This subscribes our app to changes in the characteristic's value. The device will now push data to us whenever the heart rate changes.
  • characteristicvaluechanged Event: We listen for this event to receive the data. The value is a DataView object, which is a low-level interface for reading binary data.
  • parseHeartRate: This function decodes the DataView object. According to the Bluetooth GATT specification for heart rate, the first byte is a "flags" field. The very first bit of this flag tells us if the heart rate value is 8-bits or 16-bits. We check this flag and then read the appropriate number of bytes.

Step 3: Visualizing the Data with a Chart

Displaying the BPM is great, but a chart provides a much richer view of the data over time.

What we're doing

We will integrate recharts to create a line chart that updates in real-time as we receive new heart rate data.

Implementation

Let's create a HeartRateChart component and update App.js to manage the data history for the chart.

code
// src/HeartRateChart.js
import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';

const HeartRateChart = ({ data }) => {
  return (
    <ResponsiveContainer width="80%" height={300}>
      <LineChart
        data={data}
        margin={{
          top: 5, right: 30, left: 20, bottom: 5,
        }}
      >
        <CartesianGrid strokeDasharray="3 3" />
        <XAxis dataKey="time" />
        <YAxis domain={}/>
        <Tooltip />
        <Legend />
        <Line type="monotone" dataKey="heartRate" stroke="#8884d8" activeDot={{ r: 8 }} />
      </LineChart>
    </ResponsiveContainer>
  );
};

export default HeartRateChart;
Code collapsed

Now, let's update App.js to store a history of heart rate data and pass it to our new chart component.

code
// src/App.js (updated)
import React, { useEffect, useState } from 'react';
import useBluetooth from './useBluetooth';
import HeartRateChart from './HeartRateChart';
import './App.css';

function App() {
  const { device, connect, disconnect, heartRate } = useBluetooth();
  const [data, setData] = useState([]);

  useEffect(() => {
    if (heartRate) {
      const now = new Date();
      const newEntry = {
        time: `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`,
        heartRate,
      };
      setData(prevData => {
        const newData = [...prevData, newEntry];
        // Keep only the last 30 data points
        return newData.length > 30 ? newData.slice(newData.length - 30) : newData;
      });
    }
  }, [heartRate]);

  // We need to call startNotifications from the hook
  // We'll manage that inside an effect that depends on the server object
  const { startNotifications } = useBluetooth(); // Assume hook exports this

  // Let's adjust the hook slightly to make this cleaner
  // For the article, let's assume the hook is extended to automatically start notifications
  // Or we can call it here. For clarity, let's call it.
  const bluetooth = useBluetooth();

  useEffect(() => {
      if (bluetooth.server) {
          bluetooth.startNotifications();
      }
  }, [bluetooth.server])


  return (
    <div className="App">
      <header className="App-header">
        <h1>Heart Rate Dashboard</h1>
        {!bluetooth.device ? (
          <button onClick={bluetooth.connect}>Connect to Heart Rate Monitor</button>
        ) : (
          <button onClick={bluetooth.disconnect}>Disconnect</button>
        )}
        {bluetooth.device && <p>Connected to: {bluetooth.device.name}</p>}
        <div className="heart-rate-display">
          {bluetooth.heartRate ? `${bluetooth.heartRate} BPM` : '...'}
        </div>
        <HeartRateChart data={data} />
      </header>
    </div>
  );
}

export default App;
Code collapsed

How it works

  • Data History: We create a new state variable data in App.js to store an array of heart rate readings.
  • useEffect for Data Updates: We use a useEffect hook that runs whenever heartRate changes. It creates a new data entry with a timestamp and the heart rate, and appends it to our data array.
  • Sliding Window: To prevent the chart from becoming cluttered, we only keep the last 30 data points. This creates a "sliding window" effect.
  • recharts Components: We use several components from the recharts library to build our chart. LineChart is the container, Line defines the data series, and XAxis, YAxis, and CartesianGrid draw the chart's structure.

Putting It All Together

Here's the final version of our useBluetooth.js hook for clarity.

code
// src/useBluetooth.js (final)
import { useState, useCallback } from 'react';

const useBluetooth = () => {
    const [device, setDevice] = useState(null);
    const [server, setServer] = useState(null);
    const [heartRate, setHeartRate] = useState(null);
    const [error, setError] = useState(null);

    const handleHeartRateChange = useCallback((event) => {
        const value = event.target.value;
        const flags = value.getUint8(0);
        const rate16Bits = (flags & 0x1) !== 0;
        let newHeartRate;
        if (rate16Bits) {
            newHeartRate = value.getUint16(1, true); // true for little-endian
        } else {
            newHeartRate = value.getUint8(1);
        }
        setHeartRate(newHeartRate);
    }, []);

    const startNotifications = useCallback(async (gattServer) => {
        try {
            const service = await gattServer.getPrimaryService('heart_rate');
            const characteristic = await service.getCharacteristic('heart_rate_measurement');
            await characteristic.startNotifications();
            characteristic.addEventListener('characteristicvaluechanged', handleHeartRateChange);
        } catch (err) {
            console.error(err);
            setError(err.message);
        }
    }, [handleHeartRateChange]);


    const onDisconnected = useCallback(() => {
        console.log('Device disconnected');
        setDevice(null);
        setServer(null);
        setHeartRate(null);
    }, []);

    const connect = async () => {
        try {
            if (!navigator.bluetooth) {
                throw new Error('Web Bluetooth API is not available.');
            }
            const bleDevice = await navigator.bluetooth.requestDevice({
                filters: [{ services: ['heart_rate'] }],
            });
            bleDevice.addEventListener('gattserverdisconnected', onDisconnected);
            const gattServer = await bleDevice.gatt.connect();
            setDevice(bleDevice);
            setServer(gattServer);
            setError(null);
            await startNotifications(gattServer);
        } catch (err) {
            console.error(err);
            setError(err.message);
        }
    };

    const disconnect = () => {
        if (device && device.gatt.connected) {
            device.gatt.disconnect();
        }
    };

    return { device, heartRate, error, connect, disconnect };
};

export default useBluetooth;
Code collapsed

Now our App.js is simpler and the hook handles starting notifications internally.

code
// src/App.js (final)
import React, { useEffect, useState } from 'react';
import useBluetooth from './useBluetooth';
import HeartRateChart from './HeartRateChart';
import './App.css'; // Make sure to create some basic styles

function App() {
  const { device, heartRate, error, connect, disconnect } = useBluetooth();
  const [data, setData] = useState([]);

  useEffect(() => {
    if (heartRate) {
      const now = new Date();
      const newEntry = {
        time: `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`,
        heartRate,
      };
      setData(prevData => {
        const newData = [...prevData, newEntry];
        return newData.length > 30 ? newData.slice(1) : newData;
      });
    }
  }, [heartRate]);

  return (
    <div className="App">
      <header className="App-header">
        <h1>❤️ Real-Time Heart Rate Dashboard</h1>
        {!device ? (
          <button onClick={connect}>Connect to Heart Rate Monitor</button>
        ) : (
          <button onClick={disconnect}>Disconnect</button>
        )}
        {error && <p className="error">Error: {error}</p>}
        {device && (
          <div className="device-info">
            <p>Connected to: <strong>{device.name}</strong></p>
          </div>
        )}
        <div className="heart-rate-display">
          <h2>{heartRate ? `${heartRate} BPM` : '...'}</h2>
        </div>
        <HeartRateChart data={data} />
      </header>
    </div>
  );
}

export default App;

Code collapsed

Security Best Practices

  • User Consent is Key: The Web Bluetooth API is designed with security in mind. A site can't scan for devices without explicit user permission via the device picker.
  • Secure Context: Always serve your application over HTTPS in production. Browsers enforce this to prevent man-in-the-middle attacks.
  • Limited Scope: Only request the services you absolutely need. In our case, we only request 'heart_rate', minimizing the data our app has access to.

Conclusion

We've successfully built a functional real-time heart rate dashboard with React and the Web Bluetooth API. We've seen how to discover and connect to BLE devices, manage connection state with custom hooks, parse binary data from a device, and visualize it with a live chart.

This project just scratches the surface of what's possible. You could extend this by:

  • Calculating average heart rate.
  • Storing and retrieving session data using localStorage.
  • Adding support for other BLE services, like battery level.
  • Deploying it as a Progressive Web App (PWA) for a native-like experience.

The Web Bluetooth API bridges the gap between the web and the physical world, creating endless possibilities for interactive and useful applications. ✨

Resources

#

Article Tags

reactjavascriptwebbluetoothiothealthtech
W

WellAlly's core development team, comprised of healthcare professionals, software engineers, and UX designers committed to revolutionizing digital health management.

Expertise

Healthcare TechnologySoftware DevelopmentUser ExperienceAI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey

© 2024 康心伴 WellAlly · Professional Health Management