Lattice Grid Buy a licence

the realtime layer

Realtime Data Grid: One Feed, Many Live Views

The realtime layer for your data grid. The screen a live desk watches is never one table. It is several related views moving at once because the same events are landing behind them. The Data Router lets you build that screen from a single feed: point it at one stream, partition it by a property, and each grid and chart fills itself with only its own slice, live. No connection per panel, and no one giant grid filtered to death.

why it is worth the layer

One source of truth, kept in one place.

When a screen has to show several related things from one live source, the shortcut is usually one of two shapes, and both go wrong in the same week.

A connection per panel

Each table and chart opens its own socket, so you are running several feeds of the same truth. They drift: one reconnects and the others do not, one lags a few seconds, and the numbers on the screen stop adding up because they were read at different moments.

One giant grid, filtered six ways

A single table holds everything and gets sliced to pull each view out of it. It is slow to paint, awkward to sort, and impossible to reason about, because a change to one view is a change to the same object every other view is reading.

The Data Router takes the other path. One stream comes in, one router owns the routing, and the views behind it stay plain grids and charts that only know how to show the rows they are given. The picture on the screen always adds up, because every panel is reading the same tick of the same feed.

a router in front, plain views behind

The wiring reads the way the screen looks.

You build plain grids that only know how to show rows, and you put one router in front of them. createDataRouter takes the property to partition on and the row key; attach points each view at the slice it should receive. load fans one opening snapshot across every view, and apply delivers each record in a mixed live batch only to the views it belongs in, through each grid's own keyed update path.

link is the one connection you usually do want: pick a run of rows in one grid and a linked view narrows to the related rows on a shared field. A headless grid feeding a chart is just another view, so a live chart drops into the same call.

By default each record goes to the first view that matches it. If you wire two views to claim the same value by accident, the router tells you rather than letting rows quietly go to one and not the other, and it points you at the one setting that fans a value out to several views when that is what you actually meant. A whole class of silent misrouting is caught for you.

The router is a separate, opt-in module with a global of its own. It changes nothing in the grid core and composes with the charts, streaming and statistics modules, so the whole screen is a system rather than a grid. See its size next to every other module in the module map.

See the full build, line by line → Read the full router API →

<!-- the grid and the data router, as globals -->
<script src="https://cdn.latticegrid.dev/lattice-grid@1.108.1/lattice-grid.min.js"></script>
<script src="https://cdn.latticegrid.dev/lattice-grid@1.108.1/modules/data-router.min.js"></script>

<script>
  var g = LatticeGrid;
  var createDataRouter = LatticeGridDataRouter.createDataRouter;

  // Plain views. None of them knows about the feed, or about each other.
  var orders  = g.createGrid(document.querySelector('#orders'),  { rowKey: 'id', columns: orderCols });
  var returns = g.createGrid(document.querySelector('#returns'), { rowKey: 'id', columns: returnCols });
  var metrics = g.createHeadlessGrid({ rowKey: 'ts', columns: metricCols });

  // One router. Partition the feed on "type", then attach each view to its slice.
  var router = createDataRouter({ key: 'type', rowKey: 'id' });
  router.attach(orders,  'order');
  router.attach(returns, 'return');
  router.attach(metrics, 'metric', { rowKey: 'ts' });

  // Pick a run of orders and the returns grid narrows to the same regions.
  router.link(orders, returns, { from: 'region', to: 'region' });

  // The opening snapshot, split across every attached view in one call.
  router.load(snapshot);

  // The live feed: a mixed batch each tick, each record delivered only where it belongs.
  socket.onmessage = function (e) { router.apply(JSON.parse(e.data)); };
</script>

the same shape, four desks

One feed, many views, wherever the desk changes.

Once the feed and the views are separate, the same pattern fits screens that look nothing alike. The desk changes and the partition key changes; the code does not. Each of these is a prebuilt starter kit: open it, watch it move, and make it yours.

See all four starter kits →

what the router handles for you

The part that used to rot, owned in one place.

Partitioned by property

One feed is split by a property you name, and each grid or chart only ever sees its own slice. A record that changes which slice it belongs to moves rather than duplicating.

Updates in place, state kept

Changes land as a keyed diff on the one row they touch, so every view keeps its scroll, selection, sort and filter while the feed keeps arriving. Only the affected view repaints.

Nothing dropped silently

A record that matches no view is counted and surfaced, not thrown away, so a feed that grows a new kind of event tells you rather than losing it without a trace.

Cross-grid selection

Pick a row in one grid and a linked view narrows to the related rows on a shared field, so a reader drills from one view into another with a single click while the feed runs underneath. Select every matching row on a table too large to hold in the browser, not just the page loaded, and the link still narrows correctly, provided the relation on the other end is written to accept a filter rather than a fixed list of keys.

Grids and charts alike

A headless grid feeding a chart is just another attached view, so a live line or bar takes its slice of the same feed the tables do, with no extra plumbing.

A separate, opt-in module

The router loads only where a page needs it, changes nothing in the grid core, and composes with the charts, streaming and statistics modules. A page that never routes never pays for it.

Because the router applies plain JavaScript changes from any source, you can drive the whole screen from a mock feed and build the real thing before the backend exists. The operations console tutorial does exactly that, then swaps one line to go live.

once it is in place

The whole screen, not just the split.

Splitting the feed is the start. The same router carries the rest of what a live screen needs, so the parts that usually bolt on later are already here.

Shape each view before it lands

A panel rarely wants the raw records. A view can be narrowed to just the rows that matter, sorted the way it reads, or rolled up into a per-group summary for a chart, and the grid behind it stays plain. Each panel shows exactly what it is for, off the one feed.

Relationships across the whole screen

Selection does not have to stop at two grids. Describe how the views relate and a pick in one narrows the rest along the chain: choose a customer and their orders narrow, and the lines on those orders narrow with them. The whole screen follows one click.

Calm under a real feed

A production feed arrives out of order, gets replayed after a reconnect, and comes faster than a screen should repaint. The router keeps the newest state for each record, folds a burst of updates into a single repaint, and picks up where it left off after a dropped connection, so the screen stays correct and steady under load.

Step back through what happened

Turn on recording and you can scrub the whole screen back to any moment, or replay a stretch of the feed at your own pace, then drop back to live. Every grid and chart moves together, so you see exactly what the desk saw when something went wrong. If a view's own row key does not match the key the router uses for a record, it says so once rather than quietly leaving a stale row behind.

The same picture in every tab

Open the screen in a second tab and it mirrors the first: one tab holds the feed and the others follow it live, so a wall of monitors shows one consistent picture without a connection behind each pane.

Route straight from a query

When the data sits behind a query rather than a socket, point the router at your query source and it splits one result across the same views, sending each panel's own filter down to the engine so only the rows it needs come back. A DuckDB or DFQL source drops in without changing the views, the same way a security log triage screen sends every filter to DuckDB as SQL.

a live screen, both ways

More than a live read.

A screen driven by a feed does not have to be a screen you only watch. The router carries changes back to where the data lives, takes more than one feed at a time, and tells you what it is doing while it runs.

Changes travel back to the source

Let a view be editable and the router closes the loop: an edit someone makes in a grid is sent back to the source it came from, reconciled against what the source returns, and rolled back on the cell if the source refuses it. It is the same commit path the grid uses for any inline edit, so a routed screen is a place people change things, not just a place they read.

More than one feed, one screen

A real screen rarely has a single source. The router takes several feeds at once, each one named, and folds them into the one picture it fans out to the views. Orders on one stream and telemetry on another land on the same screen, kept apart or brought together as the layout needs, and a feed can be added or dropped without disturbing the views.

See what it is doing

A live screen should not be a black box. The router reports what it is handling as it runs: how much each view holds and how much it is showing, how fast each feed is arriving, how many records matched no route, and how far behind it is running. You can tell at a glance that the screen is keeping up and that nothing is being dropped.

one row, several feeds

Build one row out of many feeds.

Real data rarely arrives as one flat table. A join can gather every matching row from another feed into one row, as a list or as counts, and keep gathering through as many levels as your data actually has, live, the whole way down.

A company, its addresses, its countries

An address looks up its country's name through an ordinary join. A company then collects its addresses' countries into one list column, distinct and sorted, or, with the same relationship, rolls them up into an address count and a distinct country count on the company row instead. Rename a country and every address, and every company that collects from it, updates on its own.

A join can also unnest a nested array already sitting inside an incoming record into its own set of related rows, or spread a feed of custom-field records across named columns as they arrive. Deleting a parent row then decides what happens to the children it leaves behind: cascade the delete to them, mark them as orphaned, or leave them as they are.

Read the full join reference →

const countries = router.addSource('country');
const address = router.addSource('address', {
  join: { from: 'country', localKey: 'countryId', fields: { name: 'countryName' } },
});
const company = router.addSource('company', {
  join: {
    from: 'address', foreignKey: 'companyId', many: true,
    select: (a) => a.countryName, distinct: true, sort: true,
    as: 'countries',            // -> a list column: "France,Germany"
  },
});

// Roll the same relationship up into counts instead of a list, in one more join:
router.addSource('company', {
  join: {
    from: 'address', foreignKey: 'companyId', many: true,
    aggregate: {
      addressCount: { fn: 'count' },
      countriesCount: { field: 'countryName', fn: 'distinctCount' },
    },
  },
});

build it before the backend

Build a realtime UI with no backend.

The hardest part of a live screen is often the part that is not ready yet: the server that will one day push the feed. You do not have to wait for it. A stand-in socket comes with the grid, so you can build the whole realtime UI now, against a feed that behaves like the real one.

A feed that matches the real thing

The mock socket loads as a plain script tag beside the grid, on the same global, so a page wires it up with no build step. It presents the same surface as the browser's WebSocket: the same onmessage, the same readyState, the same send and close. It opens, sends an opening snapshot, then streams changes on a timer from a generator you write, or from a ready-made one that comes with it. The code you read it with is the code you ship.

One line to go live

When the server is ready, you swap the one line that builds the mock for a real WebSocket. Everything that parses each message and routes it stays exactly as written, because you wrote it against the real socket's surface from the start.

It is deterministic, so the same seed replays the same stream and a screen you are building shows the same thing every time you reload it. Point the mock at the router and one feed fills a whole screen of grids and a live chart, with nothing running behind it.

Run the realtime example in the sandbox → Build it step by step →

Build the screen on localhost, free.

Development on localhost needs no licence key, so you can wire the router to your feed and see the real screen move before you decide anything. Licence per domain when you ship.