Build a live forex chart with React

In this tutorial you build a live EUR/USD chart: a Node.js process publishes rates every two seconds, and a React app receives them through BigState WebSocket delivery.

The EUR/USD chart updates in real time as new rates arrive through WebSocket delivery

What you will build

A Node.js publisher and a React chart that render live EUR/USD rates.

What you will learn

How to publish object state from a backend and subscribe with useBigState.

What you need

Node.js 22.12+, your BigState owner, and server plus browser API keys.

Node publisher → BigState object state → WebSocket delivery → React chart

The publisher writes JSON prices to forex:eurusd@YOUR_OWNER. A WebSocket delivery routes those updates to the React app, which keeps a short history of points and draws them with Recharts.

Keep privileged keys off the client

Vite exposes every VITE_ variable in the browser bundle.

Use a server-side key for the publisher. Give the React app a restricted browser key. Never put a root production key in VITE_BIGSTATE_API_KEY.

Before you begin#

You need:

  1. Node.js 22.12 or later.
  2. A BigState owner identifier, used to namespace the forex object.
  3. A server API key for the publisher, and a restricted browser API key for the React app.

If you do not have these values yet, follow Create an account and save your credentials.

You can also fork the live StackBlitz demo or browse the source in bigstate-examples.

Create the project#

Create a project with a publisher and a React client — the same layout as the live StackBlitz demo and the bigstate-examples starter:

mkdir bigstate-forex
cd bigstate-forex
npm init -y
npm install --save-dev concurrently
npm create vite@latest client -- --template react
mkdir publisher
bigstate-forex/
├── client/ # React chart (Vite)
├── publisher/ # Node.js publisher
└── package.json

Replace the scripts section in the root package.json:

{
"scripts": {
"start:client": "npm run start --prefix client",
"start:server": "npm run start --prefix publisher",
"dev": "concurrently -k -n publisher,client \"npm run start:server\" \"npm run start:client\""
}
}

Add credentials#

Create publisher/.env for the publisher:

BIGSTATE_OWNER=YOUR_OWNER
BIGSTATE_API_KEY=YOUR_SERVER_API_KEY

Create client/.env for the React app:

VITE_BIGSTATE_OWNER=YOUR_OWNER
VITE_BIGSTATE_API_KEY=YOUR_RESTRICTED_BROWSER_API_KEY

Both owner values must match. If you do not have credentials yet, follow Create an account and save your credentials.

Create the publisher#

Inside publisher/:

cd publisher
npm init -y
npm install bigstate.client.javascript

Add a start script to publisher/package.json:

{
"type": "module",
"scripts": {
"start": "node --env-file=.env index.js"
}
}

Create publisher/index.js. This file configures the forex object and delivery, then publishes a new price every two seconds:

import {
BigStateHttpClient,
DeliveryType,
ObjectStateType,
} from 'bigstate.client.javascript';
const { BIGSTATE_OWNER: owner, BIGSTATE_API_KEY: apiKey } = process.env;
if (!owner || !apiKey) {
throw new Error('BIGSTATE_OWNER and BIGSTATE_API_KEY are required.');
}
const FOREX_OBJECT = `forex:eurusd@${owner}`;
const FOREX_DELIVERY = 'deliveryWsForex';
const client = new BigStateHttpClient({
baseUrl: 'https://api.bigstate.dev',
apiKey,
});
const configureResources = async () => {
const { error: objectError } = await client.objectCreate(FOREX_OBJECT, {
type: ObjectStateType.JSON,
ttl: 100000,
validity: 60,
versionDeep: 50,
info: {
name: 'Forex rate: EUR/USD',
desc: 'Simulated EUR/USD rates for the React tutorial',
example: { price: 1.08 },
},
});
if (objectError) {
throw new Error(`Could not configure object: ${objectError.message}`);
}
const { error: deliveryError } = await client.deliveryCreate(FOREX_DELIVERY, {
name: 'Forex WebSocket delivery',
type: DeliveryType.WS,
objects: [FOREX_OBJECT],
});
if (deliveryError) {
throw new Error(`Could not configure delivery: ${deliveryError.message}`);
}
};
let currentPrice = 1.08;
const getPrice = () => {
const change = (Math.random() - 0.5) * 0.006;
currentPrice = Math.max(1.05, Math.min(1.15, currentPrice + change));
return Number(currentPrice.toFixed(5));
};
const publishRate = async () => {
const state = {
at: new Date().toISOString(),
value: { price: getPrice() },
};
const { error } = await client.stateSet(FOREX_OBJECT, state);
if (error) {
console.error('Could not publish rate:', error);
return;
}
console.log(`${FOREX_OBJECT}:`, state.value.price);
};
await configureResources();
console.log(`Publishing ${FOREX_OBJECT} every two seconds.`);
setInterval(publishRate, 2000);
await publishRate();

Subscribe from React#

Inside client/:

cd client
npm install
npm install bigstate.client.react
npm install recharts

Add a start script to client/package.json if it is missing:

{
"scripts": {
"dev": "vite",
"start": "npm run dev"
}
}

Replace client/src/App.jsx:

import { useEffect, useState } from 'react';
import { useBigState } from 'bigstate.client.react';
import { EurUsdChart } from './components/EurUsdChart';
const owner = import.meta.env.VITE_BIGSTATE_OWNER;
const apiKey = import.meta.env.VITE_BIGSTATE_API_KEY;
if (!owner || !apiKey) {
throw new Error(
'VITE_BIGSTATE_OWNER and VITE_BIGSTATE_API_KEY are required.',
);
}
const FOREX_OBJECT = `forex:eurusd@${owner}`;
const FOREX_DELIVERY = 'deliveryWsForex';
const App = () => {
const [points, setPoints] = useState([]);
const { subscribeLastChanged } = useBigState(
[FOREX_OBJECT],
{
baseUrl: 'https://api.bigstate.dev',
apiKey,
},
{
baseUrl: 'https://ws.delivery.bigstate.dev',
apiKey,
deliveries: [FOREX_DELIVERY],
},
);
useEffect(() => {
const unsubscribe = subscribeLastChanged(lastChanged => {
const state = lastChanged?.value?.currState;
if (!state?.value?.price || !state?.at) return;
const date = new Date(state.at);
const pad = n => String(n).padStart(2, '0');
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds(),
)}`;
setPoints(previous =>
[...previous, { time, rate: state.value.price }].slice(-50),
);
});
return unsubscribe;
}, [subscribeLastChanged]);
return (
<main className="app-shell">
<EurUsdChart data={points} />
</main>
);
};
export default App;

Add the chart#

Create client/src/components/EurUsdChart.jsx:

import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import './EurUsdChart.css';
const RateTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div className="eurusd-tooltip">
<div className="eurusd-tooltip-title">EUR / USD</div>
<div className="eurusd-tooltip-rate">{payload[0].payload.rate}</div>
<div className="eurusd-tooltip-time">{label}</div>
</div>
);
};
export const EurUsdChart = ({ data }) => {
if (!data?.length) {
return <div className="eurusd-empty">Waiting for EUR / USD updates…</div>;
}
const first = data[0];
const last = data.at(-1);
const changePercent = ((last.rate - first.rate) / first.rate) * 100;
const isUp = changePercent >= 0;
return (
<section className="eurusd-card">
<header className="eurusd-header">
<div>
<div className="eurusd-title">EUR / USD forex rate</div>
<div className="eurusd-rate-row">
<span className="eurusd-rate-value">{last.rate}</span>
<span className={`eurusd-badge ${isUp ? 'up' : 'down'}`}>
{isUp ? '▲' : '▼'} {Math.abs(changePercent).toFixed(3)}%
</span>
</div>
<div className="eurusd-note">
From {first.rate} to {last.rate}
</div>
</div>
<div className="eurusd-subject">
Realtime object
<div className="eurusd-subject-id">forex:eurusd</div>
</div>
</header>
<div className="eurusd-chart">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 24, right: 8, bottom: 8, left: 0 }}
>
<defs>
<linearGradient id="eurusdFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.8} />
<stop offset="95%" stopColor="#2563eb" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
stroke="#e2e8f0"
vertical={false}
strokeDasharray="4 4"
/>
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tick={{ fontSize: 11, fill: '#64748b' }}
/>
<YAxis
domain={['dataMin - 0.0002', 'dataMax + 0.0002']}
tickLine={false}
axisLine={false}
tickFormatter={value => value.toFixed(4)}
tick={{ fontSize: 11, fill: '#64748b' }}
/>
<Tooltip content={<RateTooltip />} />
<Area
type="monotone"
dataKey="rate"
stroke="#6366f1"
fill="url(#eurusdFill)"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</section>
);
};

Create client/src/components/EurUsdChart.css:

.eurusd-empty,
.eurusd-card {
width: min(100%, 64rem);
border: 1px solid #e2e8f0;
border-radius: 1rem;
background: white;
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
}
.eurusd-empty {
padding: 1.5rem;
color: #64748b;
font-size: 0.875rem;
}
.eurusd-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
}
.eurusd-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.eurusd-title,
.eurusd-subject,
.eurusd-note {
color: #64748b;
font-size: 0.6875rem;
}
.eurusd-title {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.eurusd-rate-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-top: 0.25rem;
}
.eurusd-rate-value {
color: #0f172a;
font-size: 1.75rem;
font-weight: 600;
}
.eurusd-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 500;
}
.eurusd-badge.up {
color: #047857;
background: #ecfdf5;
}
.eurusd-badge.down {
color: #b91c1c;
background: #fef2f2;
}
.eurusd-subject {
text-align: right;
}
.eurusd-subject-id {
margin-top: 0.125rem;
color: #334155;
font-family: ui-monospace, monospace;
}
.eurusd-chart {
width: 100%;
height: 24rem;
}
.eurusd-tooltip {
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.625rem;
background: white;
box-shadow: 0 4px 8px rgb(0 0 0 / 5%);
font-size: 0.75rem;
}
.eurusd-tooltip-title {
color: #0f172a;
font-weight: 600;
}
.eurusd-tooltip-rate {
margin-top: 0.25rem;
color: #334155;
}
.eurusd-tooltip-time {
margin-top: 0.25rem;
color: #64748b;
}

Replace client/src/index.css:

body {
margin: 0;
background: #0b1a2a;
}
#root,
.app-shell {
min-height: 100dvh;
}
.app-shell {
display: grid;
place-items: center;
padding: 0.75rem;
box-sizing: border-box;
}

How the code works#

The app has two processes: a Node publisher and a React subscriber. They share one object name and one delivery name.

Object and delivery#

await client.objectCreate(FOREX_OBJECT, {
type: ObjectStateType.JSON,
ttl: 100000,
validity: 60,
versionDeep: 50,
info: {
name: 'Forex rate: EUR/USD',
desc: 'Simulated EUR/USD rates for the React tutorial',
example: { price: 1.08 },
},
});
await client.deliveryCreate(FOREX_DELIVERY, {
name: 'Forex WebSocket delivery',
type: DeliveryType.WS,
objects: [FOREX_OBJECT],
});
  • FOREX_OBJECT is forex:eurusd@YOUR_OWNER — the JSON stream of prices.
  • objects: [FOREX_OBJECT] makes the delivery match that stream.
  • DeliveryType.WS sends updates to WebSocket subscribers.

Publishing rates#

await client.stateSet(FOREX_OBJECT, {
at: new Date().toISOString(),
value: { price: getPrice() },
});

Every two seconds the publisher writes a new state version:

  • at is the publication timestamp.
  • value: { price } satisfies the object's JSON type.
  • getPrice() only generates local values; BigState state is what stateSet stores.

Subscribe with useBigState#

const { subscribeLastChanged } = useBigState(
[FOREX_OBJECT],
{
baseUrl: 'https://api.bigstate.dev',
apiKey,
},
{
baseUrl: 'https://ws.delivery.bigstate.dev',
apiKey,
deliveries: [FOREX_DELIVERY],
},
);
  • The first argument lists the objects to track.
  • optionsHttp is used for initial and valueRef reads.
  • optionsWs.deliveries opens the WebSocket delivery channel.

Chart points#

subscribeLastChanged(lastChanged => {
const state = lastChanged?.value?.currState;
if (!state?.value?.price || !state?.at) return;
const date = new Date(state.at);
const pad = n => String(n).padStart(2, '0');
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds(),
)}`;
setPoints(previous =>
[...previous, { time, rate: state.value.price }].slice(-50),
);
});

Each delivery update includes the current state at lastChanged.value.currState. The component keeps the last 50 points and passes them to Recharts.

Startup order#

  1. The publisher creates the object and delivery, then starts stateSet.
  2. The React app calls useBigState and registers subscribeLastChanged.
  3. Chart points appear only from delivery messages — not from the HTTP stateSet response.

Run the app#

From the project root:

npm run dev

Open the Vite URL, usually http://localhost:5173.

You should see:

  1. Waiting for EUR / USD updates… for a moment
  2. a new chart point about every two seconds
  3. publisher logs like forex:eurusd@YOUR_OWNER: 1.08123

Troubleshooting#

  • Restart both processes after editing publisher/.env or client/.env.
  • Confirm the publisher and React app use the same owner and the delivery name deliveryWsForex.
  • A browser 403 usually means the subscriber key needs a broader policy — do not paste the publisher key into the client.
  • Check the publisher terminal and browser console for errors.

Next steps#

© 2024 BigState