I've built more forms and data grids than I can count, and no two were alike. I'll never forget the magic of Magento 2 grids, or the hours I spent finding out why the magic wasn't working. In the end it was routine work, and then just boring work.
Every time, one thought came back: why is there still no universal, plug-and-play grid? A rhetorical question with a hundred answers. Here is mine, and what I tried instead.
A grid usually knows too much
Look at any admin grid you've shipped. The backend knows which columns exist, which ones can be sorted, which filters are allowed and how big a page is. Then the frontend knows all of it again: column definitions, sort keys, filter widgets, page sizes. Two descriptions of the same thing, kept in sync by hand.
They drift. Someone adds a sortable column on the server and forgets the client. Someone adds a filter in the UI that the query silently ignores. Every "plug-and-play" grid library I've used solved the rendering and left this part to me, because it can't know my backend.
Then the grid needs to be live, and it gets worse. Now there is a client store holding a copy of the data, and a WebSocket handler trying to keep that copy honest. Two systems that must agree: the server's truth and the browser's copy.
What if the grid knew nothing?
In Semitexa, the PHP framework I'm building, I tried the opposite. The page gives the grid an endpoint and nothing else:
{% include '@platform-ui/components/runtime/grid-v2.html.twig' with {
gridId: 'showroom-exchange',
endpoint: '/_feed/exchange',
} only %}
No columns, no sort keys, no filter definitions. The grid's first request is OPTIONS /_feed/exchange, and the answer is the route contract, built from the same attributes that run the request:
{
"name": "site.showroom.exchange",
"modes": ["plain", "sse", "sse-update"],
"collection": {
"pagination": { "modes": ["page"], "defaultPerPage": 10, "perPageOptions": [10, 20, 40] },
"sort": { "fields": ["at", "asset", "trader", "amount", "status"] },
"filter": { "fields": { "asset": ["eq"], "side": ["eq"], "status": ["eq"] } },
"live": { "scopes": ["site_showroom_exchange"] }
}
}
The same declarations guard the way back in. Ask for a sort the contract doesn't offer and the request is refused before any row is read:
$ curl "https://semitexa.com/_feed/exchange?sort=lot"
HTTP 400
{"error": "invalid_sort", "allowed_fields": ["at", "asset", "trader", "amount", "status"]}
That is the part I stopped describing twice.
The table is a subscription
The second half is the live part. The grid doesn't keep a dataset. It subscribes over Server-Sent Events with its current view: page, page size, sort, search and filters. From then on the server holds that view.
Changing the sort doesn't fetch. The new view goes up as a small POST, and the answer arrives on the stream, in the same {data, meta} envelope a plain JSON request would return. The browser holds exactly one page, the one on screen.
When the data changes, the server re-runs each subscriber's own view and sends only what moved:
event: ui.collection.patch
data: {"patch": {"key": "id",
"upsert": [{"id": "TRX-985F23", "asset": "gold", "amount": "$108,268.81"}],
"remove": ["TRX-1A0C7E"],
"order": ["TRX-985F23", "TRX-4C2BBB", "TRX-673208"]}}
A visitor on page three filtered to gold gets the patch for page three filtered to gold. A visitor whose page didn't change gets nothing. Rows that didn't change keep their DOM nodes, so focus and selection survive the update.
Nothing polls. The feed declares the scope it watches, and the code that changes the data says so:
$trade = $this->trades->place($payload->getAsset());
$this->invalidator->touch('site_showroom_exchange');
The bug was production, not the grid
semitexa.com runs four Swoole workers, each with its own in-memory cache. The first version kept the demo trades in that cache, so a trade placed on one worker was invisible to feeds held by the others. Locally, with a shared Redis cache, it worked perfectly. The tape lives in MySQL now.
What it costs
Server-owned views are not free:
- One query per subscriber per publish. A thousand open grids on one scope re-run a thousand views. Patches keep the wire small, not the database work.
- Every open tab holds a connection. One stream per page instead of one per widget, but still one per tab. Turn off proxy buffering, or frames sit in a buffer.
- The grid itself is plain. No column resize, no inline editing, no charts in cells yet.
So, plug and play? Not for every stack. It works because the framework already describes its endpoints in a form the grid can read. But for the first time, a grid was the part I didn't have to describe twice.
You can try it with two tabs: place a trade in one and watch the other. The other traders are simulated, the frames are real, and a panel under the table shows each one as it crosses the wire: semitexa.com/blog/live-data-grid
Which grid cost you the most hours, and was it the rendering or keeping two descriptions in sync?

Top comments (0)