tutorial
Build a real-time operations console in React
Last updated 8 September 2026
A React operations screen shows several related things at once: orders coming in, shipments moving, incidents opening and closing, a throughput line ticking along. They are all the same live source. This tutorial takes one feed and drives the whole screen from it: three grids and a chart, each a component that only ever sees its own slice, with no backend to run.
You will build it with a stand-in socket so it runs anywhere, then change a single line to point it at a real server. The finished console is one click away if you want to see the destination first. If you would rather follow this in plain JavaScript, the vanilla version builds the same screen without a framework.
Open the finished console in the sandbox
The problem: one feed, many views
The obvious ways to build this screen both hurt. Open a socket per grid and you are running several connections that carry the same data, reconnecting several times, and reconciling them against each other. Put everything in one giant grid and you lose the thing that made the screen readable: orders, shipments and incidents are different shapes with different columns, and a reader wants them side by side, not interleaved.
The better shape is a router in front and plain components behind. One connection arrives, a router splits each record to the view it belongs in, and every grid stays simple: it is handed rows and knows nothing about the feed or its siblings. That is what the Data Router does, and it is the whole idea this tutorial builds on. You can see the end result on the Data Router demo and read the API it uses in the rows and sources reference.
Set up the page
The React adapter takes no dependency on React and carries no second copy of the
grid: you hand it your React and the grid's createGrid, and it hands
back a component. Import the charts module and the data-router module beside it,
lay out three panels and a strip for the chart in the component's markup, and
mount into one root. On localhost the grid is free to use; a deployed site is
licensed per domain, which the sandbox already carries for you.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/lattice-grid.min.css">
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f6f7f9; color: #1b2430; }
.ops { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.panel { background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; overflow: hidden; min-width: 0; }
.panel h2 { font-size: 13px; margin: 0; padding: 8px 12px; border-bottom: 1px solid #eef0f3; color: #3a4250; font-weight: 600; }
.grid { height: 300px; }
#chart { height: 200px; margin-top: 12px; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; }
@media (max-width: 820px) { .ops { grid-template-columns: 1fr; } }
</style>
<div id="app"></div>
<script type="module">
import React from 'https://esm.sh/react@18';
import { createRoot } from 'https://esm.sh/react-dom@18/client';
import { createGrid, createHeadlessGrid } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/lattice-grid.esm.min.js';
import createLatticeGrid from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/modules/react.esm.min.js';
import { createChart } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/modules/charts.esm.min.js';
import { createDataRouter } from 'https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.52.0/modules/data-router.esm.min.js';
// The adapter takes no dependency on React and carries no second grid: hand it
// both and it returns a component bound to the exact copies on the page.
const LatticeGrid = createLatticeGrid({ React, createGrid });
const { useRef, useEffect } = React;
Build the feed
A live feed sends an opening snapshot, then a stream of changes. The helper below
does exactly that from a generator, on a timer, and it presents the same surface
as the browser's WebSocket: an onmessage handler, a
readyState, send and close. Because it
matches the real thing, the code you write against it is the code you ship. This
part is the same whether you build the screen in React or in plain JavaScript.
// A serverless stand-in for a live feed. It opens, sends a snapshot, then
// streams deltas on a timer, all from a generator you hand it. To go live,
// swap the one line that constructs it for: new WebSocket(url)
function rng(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
class MockWebSocket {
constructor({ feed, rate = 1000, jitter = 0, seed = 1, snapshotDelay = 60 }) {
this.readyState = 0;
this.url = 'mock://feed';
this.onopen = this.onmessage = this.onclose = this.onerror = null;
this._feed = feed; this._rate = rate; this._jitter = jitter; this._rand = rng(seed);
this._timer = null; this._paused = false;
setTimeout(() => this._open(), snapshotDelay);
}
send() {} // a real socket sends upstream; here, ignored
pause() { this._paused = true; if (this._timer) { clearTimeout(this._timer); this._timer = null; } }
resume() { if (!this._paused) return; this._paused = false; this._schedule(); }
close() { if (this.readyState === 3) return; this.readyState = 3; if (this._timer) clearTimeout(this._timer); this._timer = null; this.onclose && this.onclose({ type: 'close' }); }
_open() { this.readyState = 1; this.onopen && this.onopen({ type: 'open' }); this._pump(); this._schedule(); }
_schedule() {
if (this._timer || this._paused || this.readyState !== 1) return;
var gap = Math.max(0, this._rate + (this._rand() * 2 - 1) * this._jitter);
this._timer = setTimeout(() => { this._timer = null; this._pump(); this._schedule(); }, gap);
}
_pump() {
if (this.readyState !== 1) return;
var next = this._feed.next();
if (next.done) return this.close();
this.onmessage && this.onmessage({ type: 'message', data: JSON.stringify(next.value) });
}
}
The generator is where your domain lives. This one emits a mixed operations
stream: orders, shipments and incidents, plus a small throughput rollup for the
chart. Every record carries a type, which is what the router will
split on, and an id, which is its key. The snapshot fills the screen;
each delta upserts a few records and occasionally resolves an incident.
// One mixed stream: orders, shipments, incidents and a throughput rollup.
// Every record carries a 'type', which is the field the router partitions on,
// and an 'id', its row key. The snapshot fills the screen; each delta upserts a
// few records and now and then resolves an incident.
function* opsFeed({ seed = 7, orders = 40, shipments = 24, incidents = 12, batch = 4 } = {}) {
var rand = rng(seed);
var REGIONS = ['EMEA', 'AMER', 'APAC'];
var CUSTOMERS = ['Northwind', 'Contoso', 'Fabrikam', 'Initech', 'Umbra', 'Globex'];
var CARRIERS = ['DHL', 'FedEx', 'Maersk', 'UPS'];
var SERVICES = ['Checkout', 'Search', 'Payments', 'Inventory', 'Auth'];
var SEVERITIES = ['low', 'medium', 'high', 'critical'];
var pick = function (list) { return list[Math.floor(rand() * list.length)]; };
var seq = 0, tick = 0;
var openInc = new Set();
var order = () => ({ id: 'ORD-' + (1000 + seq++), type: 'order', region: pick(REGIONS), customer: pick(CUSTOMERS), value: 200 + Math.round(rand() * 9800), status: 'open' });
var shipment = () => ({ id: 'SHP-' + (1000 + seq++), type: 'shipment', region: pick(REGIONS), carrier: pick(CARRIERS), units: 1 + Math.round(rand() * 400), status: 'in transit' });
var incident = () => { var id = 'INC-' + (1000 + seq++); openInc.add(id); return { id, type: 'incident', region: pick(REGIONS), service: pick(SERVICES), severity: pick(SEVERITIES), status: 'open' }; };
var metric = (events) => ({ id: 'M-' + tick, type: 'metric', t: tick, events });
var rows = [];
for (var i = 0; i < orders; i++) rows.push(order());
for (var i = 0; i < shipments; i++) rows.push(shipment());
for (var i = 0; i < incidents; i++) rows.push(incident());
for (tick = 0; tick < 12; tick++) rows.push(metric(1 + Math.floor(rand() * batch * 2)));
yield { kind: 'snapshot', rows };
for (;;) {
var changes = [];
var n = 1 + Math.floor(rand() * batch);
for (var i = 0; i < n; i++) {
var roll = rand();
changes.push({ op: 'upsert', row: roll < 0.45 ? order() : roll < 0.75 ? shipment() : incident() });
}
if (rand() < 0.5 && openInc.size) {
var id = [...openInc][Math.floor(rand() * openInc.size)];
openInc.delete(id);
changes.push({ op: 'upsert', row: { id, type: 'incident', status: 'resolved', region: pick(REGIONS), service: pick(SERVICES), severity: pick(SEVERITIES) } });
}
changes.push({ op: 'upsert', row: metric(changes.length) });
tick++;
yield { kind: 'delta', changes };
}
}
Define the columns
Declare a column set per grid at module scope, above the component. Keeping them out of the render body gives each one a stable reference, so the adapter pushes them into the grid once and never rebuilds it on a later render.
const money = { style: 'currency', currency: 'USD', decimals: 0 };
const orderColumns = [
{ field: 'id', title: 'Order', layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'customer', title: 'Customer' },
{ field: 'value', title: 'Value', type: 'number', format: money, total: 'sum' },
];
const shipmentColumns = [
{ field: 'id', title: 'Shipment', layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'carrier', title: 'Carrier' },
{ field: 'units', title: 'Units', type: 'number', total: 'sum' },
{ field: 'status', title: 'Status' },
];
const incidentColumns = [
{ field: 'id', title: 'Incident', layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'service', title: 'Service' },
{ field: 'severity', title: 'Severity' },
{ field: 'status', title: 'Status' },
];
Write the component
Each grid renders through the adapter and forwards a ref whose .grid
is the live instance. None of them takes a rows prop: the router owns
the rows, so the adapter and the router never contend for them. A headless grid,
a real grid with no table of its own, holds the rollup the chart reads.
One effect wires it all together. React runs a child's effect before its parent's,
so by the time this effect runs the three grids exist and their refs are set. It
creates the router keyed on type, attaches each grid to the value it
wants, links orders to incidents so selecting orders narrows the incidents grid to
the same regions, points the chart at the headless grid, and opens the socket. The
snapshot arrives as router.load and every delta as
router.apply, which computes a keyed difference per grid so an update
lands on exactly the row it changes, with scroll and selection preserved. The
cleanup returned from the effect closes the feed and tears the router down when the
component unmounts.
function Console() {
// Each grid forwards a ref whose .grid is the live instance the router drives.
const ordersRef = useRef(null);
const shipmentsRef = useRef(null);
const incidentsRef = useRef(null);
const chartHost = useRef(null);
useEffect(() => {
// Child effects run before this one, so the grids exist and the refs are set.
const orders = ordersRef.current.grid;
const shipments = shipmentsRef.current.grid;
const incidents = incidentsRef.current.grid;
// A headless grid holds the rollup with no table of its own; the chart reads it.
const metrics = createHeadlessGrid({
rowKey: 't',
columns: [{ field: 't', type: 'number' }, { field: 'events', type: 'number' }],
});
// One router, keyed on 'type'. Each grid is attached to the value it wants and
// only ever sees its own slice; the router fans the one feed out to all of them.
const router = createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(orders, 'order');
router.attach(shipments, 'shipment');
router.attach(incidents, 'incident');
router.attach(metrics, 'metric', { rowKey: 't' });
// Cross-grid selection: pick orders and the incidents grid narrows to their
// regions. Neither grid holds a reference to the other.
router.link(orders, incidents, { from: 'region', to: 'region' });
// The chart follows the headless grid, so it moves as the feed does.
const chart = createChart({
grid: metrics, container: chartHost.current,
type: 'line', x: 't', y: 'events', title: 'Throughput per tick',
});
// One socket, one handler. The snapshot hydrates every grid at once; each
// delta lands in place through the same keyed path, so scroll and selection hold.
const socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }), rate: 900, jitter: 300 });
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.kind === 'snapshot') router.load(message.rows);
else router.apply(message.changes);
};
// Unmount tears the feed and the router down with the component.
return () => { socket.close(); chart.destroy(); router.destroy(); metrics.destroy(); };
}, []);
return (
<div>
<div className="ops">
<div className="panel"><h2>Orders</h2>
<LatticeGrid ref={ordersRef} rowKey="id" theme="light" selection="multiple" columns={orderColumns} className="grid" /></div>
<div className="panel"><h2>Shipments</h2>
<LatticeGrid ref={shipmentsRef} rowKey="id" theme="light" columns={shipmentColumns} className="grid" /></div>
<div className="panel"><h2>Incidents</h2>
<LatticeGrid ref={incidentsRef} rowKey="id" theme="light" columns={incidentColumns} className="grid" /></div>
</div>
<div id="chart" ref={chartHost}></div>
</div>
);
}
Mount it
Render the component into the page. One round trip hydrates every grid and the chart at once, and the stream keeps them all moving from there.
createRoot(document.getElementById('app')).render(<Console />);
That is the whole console: one feed, three grids, a chart, and a link between two of the grids. Run it in the sandbox and edit any part of it live.
Go to production
When there is a real server to talk to, the change is one line inside the effect. Everything that parses messages and routes them stays exactly as written, because the stand-in was built to the real socket's surface from the start.
// The whole change from mock to live is this one line, inside the effect:
const socket = new WebSocket('wss://ops.example.com/stream');
// everything that parses messages and routes them stays exactly as written.
Two notes for the live version. A deployed page needs a licence for its domain, which is a single call the sandbox already makes for you. And where a connection can drop, a reconnect that asks the server for a fresh snapshot restores the screen from the same handler you already have: the snapshot path rebuilds every grid, and the deltas carry on.
What you built
One round trip hydrated a whole screen, and one stream keeps it moving. Each grid is an independent component that knows nothing of the others, the chart rides the same feed, and the connection count stayed at one. Swapping the mock for a real socket is the only change between this page and production.
Next, see the Data Router demo for the same idea as a compact example, read the React adapter guide for the props and the ref in detail, or browse the demo catalogue for the grid features each panel here is using.