Plain JavaScript. Then Angular. Then React. Then Vue. Then Blazor. Now Svelte. Same component every time: a data grid. Rows, columns, click to sort, drag to resize, select a range, keyboard arrows that do the thing people expect, virtual scrolling so it does not die at 100k rows. Six implementations, six reactivity models, six sets of idioms I had to learn well enough to argue about.
You would think by the third one I had it figured out. I did not. Each port taught me something, but the thing that actually stuck, the lesson that survived across all six, did not show up until I was deep into the Svelte version (svgrid, if it matters, though it really does not for this story). And it was a little embarrassing when it landed, because it had nothing to do with grids and nothing to do with any particular framework. It was about a line I kept refusing to draw.
A grid is deceptively small at the start. You have rows, you render some cells, you wire up a sort. In every framework the first version looks basically the same once you squint past the syntax: state and markup, fused at birth.
let rows = [];
let sortKey = null;
let sortDir = 'asc';
function sortBy(key) {
sortDir = key === sortKey && sortDir === 'asc' ? 'desc' : 'asc';
sortKey = key;
rows = [...rows].sort(/* you know how this goes */);
}
// ...and right next to it, the row markup, with the columns hardcoded in
Ships fast. Demos great. Then the real requirements arrive. Someone wants the same sorting and selection behavior with a totally different look. Someone wants a tree grid. Someone wants the cells rendered their way, with their components inside. And because the sorting logic and the row markup grew up in the same room, you cannot hand over one without the other.
So you start bolting. A variant prop. A compact prop. Slots, render props, templates, whatever that framework calls "let the user inject markup." Each ecosystem has its own escape hatch and I have used all of them: Angular's ng-template and TemplateRef, React render props and then children-as-function, Vue scoped slots, Blazor RenderFragment<T>. Six different names for the same confession: I fused two things that should never have been fused, and now I am selling the user a kit to pry them back apart.
For years I read this as a framework problem. Angular's change detection is heavy, React re-renders too much, Blazor's render tree is awkward, surely the next framework will make it clean. It never did. A bug that ports flawlessly across six reactivity models is not a framework bug. It is me.
There was never a place in any of those codebases that said, in plain terms: this part is behavior, this part is presentation, and here is exactly how they are allowed to talk. The two were welded from line one, and every template prop after that was me negotiating a divorce between two things I never gave separate rooms.
"Headless" is the fix, and I wince at the word because marketing chewed it up. People hear headless and think unstyled, bring-your-own-CSS. That is the boring part. The real point, the part that finally fixed my head on the sixth try, is that going headless does not let you keep the fusion. It forces you to write down the contract.
You have to answer the questions I dodged in five languages:
toggleSort(key), select(id), focusCell)That last one is the whole game, and it is the one every template-prop API quietly skips. The React headless libraries nailed this years ago with the getRowProps idea, and I walked right past it five times because I was busy naming boolean props. When I finally did it deliberately in the Svelte version, the runes fit it almost too neatly.
Behavior in its own file, markup left entirely to the caller:
<script>
import { createGrid } from './grid.svelte.js';
let { data, columns, row } = $props();
const grid = createGrid({ data, columns });
</script>
{#each grid.rows as r (r.id)}
{@render row(r, grid.getRowProps(r))}
{/each}
// grid.svelte.js
export function createGrid({ data, columns }) {
let sortKey = $state(null);
let sortDir = $state('asc');
let selected = $state(new SvelteSet());
const rows = $derived(applySort(data, sortKey, sortDir));
return {
get rows() { return rows; },
toggleSort(key) { /* ... */ },
getRowProps(r) {
return {
'aria-selected': selected.has(r.id),
onclick: () => selected.add(r.id),
tabindex: -1,
};
},
};
}
getRowProps is the thing I was missing for six builds. It is the contract, written down, in one readable place. The caller spreads it onto whatever element they want, my keyboard handling and ARIA ride along for free, and the look is entirely theirs. No denseCompact. No RenderFragment gymnastics. No archaeology.
And here is what would have saved me a decade: this is not a Svelte idea. It is the same contract in every one of those frameworks. It is a getRowProps you spread in React, a directive that exposes context in Angular, a scoped slot payload in Vue, a typed parameter object in a Blazor RenderFragment<T>. The framework changes how you spell it. The contract is identical. I just never wrote it down until the sixth try.
The unglamorous caveats, because I have watched this idea ruin perfectly good afternoons too.
It is more work up front. A fused component ships faster for the first use case, every time. If you have a table that will only ever be one table, headless is overengineering and you should write the boring version and go home. It earns its keep when there is a second consumer, or when the behavior is genuinely gnarly: grids, comboboxes, date pickers, trees, anything with keyboard navigation that makes you sad. Do not headless your footer. I am begging.
And the clean separation leaks at the edges. Virtual scrolling is the one that always gets me, in all six. The behavior layer needs row heights to know what is visible, but heights are a rendering concern, so you end up passing measurement callbacks back across the boundary you worked so hard to draw. It gets muddy. That is fine. If your headless boundary never leaks, the component was probably simple enough that it did not need to be headless.
Part of it is just repetition wearing me down. But part of it is real, and it is about cost.
Old mental model, the one I carried through the Angular and React versions: if I expose my state and let the consumer render it, their render runs on every change and I am back to memoizing everything to stop it melting. The boundary was where performance quietly leaked out. Angular would re-run change detection across the tree, React would re-render half the table, and the headless seam was exactly where that pain concentrated.
The signals-based reactivity changes the math. Svelte runes are where I happened to feel it, but Solid has done this for a while and Vue's reactivity core works the same way. A derived value recomputes only when its real dependencies change, and only the DOM that reads it updates. So I hand the caller a derived list of visible rows, they toggle one selection, and one node updates instead of the whole grid redrawing. That is the gap between a headless grid that feels native and one that stutters the moment you scroll past a few thousand rows. The architecture argument for headless is old, I had heard it, I shrugged. What is new is that the runtime stopped charging me rent for taking it seriously.