UI 元件已死:轉向面向 Agent 的 Region 系統
Components Are Dead; And Now for Something Completely Different
Jay 提出 Region 系統,主張它取代元件成為 UI 標記組合的預設方式,因為 Agent 更需要本地上下文和可驗證的正確性。編譯器在建置時解析組合,頁面標記集中於單一檔案,validate 會檢查內容是否偏離來源範本。可重用的邏輯仍由 headless component 共用,標記則保留在各頁面本地。
Components are dead. Not broken - dead, the way an abstraction dies when the problem it solved stops being the one that matters. The component system was the right answer to a human problem: a person can't hold a whole UI in their head, can't keep the same markup correct in fifty places, can't read a flat ocean of HTML. So we learned to write UI once and reference it everywhere, and we built a generation of tooling to keep that reference honest. It is a beautiful solution - to a constraint that is no longer the binding one. More and more, the code in a UI is written and edited by agents, and an agent does not have the human constraint. It has the opposite one.
Most UI frameworks give you a component system: you define a component once, reference it from many places, and pass props. When a consumer needs to tweak something the component didn't anticipate, you go back and add an override point - a className prop, a slot, a render-prop, a variant enum - and thread it through the component's API. The component is the single source of truth; every consumer reaches it through that API.
Jay took a different turn. After iterating on component composition across [DL#181, DL#187, DL#189, DL#190, DL#193, DL#194], a retrospective in [DL#195] made us realize we were fighting the runtime component boundary itself; stepping back in [DL#196], we threw out those cross-boundary mechanisms and landed on what we now call a region system.
This post is about why - what a region is, what it buys you over a component, the trade-offs we took on deliberately, and the argument underneath the headline: components are for humans, regions are for agents.
The thesis, up front
A component system spends its complexity repeatedly, at runtime: every render, the framework reconstructs the composed UI from instances, closures, and context - and asks you to help it do that correctly. A region system spends its complexity once, at build time: the compiler resolves the composition into a small, fixed set of runtime primitives before the page ever runs.
That build-time move is what makes the rest possible, including the claim in the title - which we argue in full in the next section. The short version: the component abstraction was shaped by human limits, and agents don't share them. Resolving composition at build time is what lets us drop the abstraction without losing the design system behind it.
Everything else in this post - locality, pre-planning-free flexibility, the size trade-off - falls out of that one move. We'll build up to it, but that's the bet.
Components are for humans. Regions are for agents.
That's the claim in the title, stated plainly. Here is the argument.
Why the component abstraction exists. Reference-don't-repeat, single source of truth, decomposition into files - these are not laws of user interfaces. They are memory-management tools for people. We factor a <Card> out into its own file because a human cannot keep fifty copies of the same markup correct by hand, cannot hold the whole rendered tree in working memory, and cannot read a ten-thousand-line page. The indirection - the hop from the page to the component to the base component to the theme - is the price we pay to shrink what any one person has to understand at once. For a human, that trade is overwhelmingly worth it.
For an agent, every one of those aids is a cost. An agent's binding constraint isn't memory - it's grounded context and verification. It works from what it can pull into its window, and every hop across the API is another file to retrieve, another chance to grab the wrong version, exhaust the context, or hallucinate a prop that was never exposed. "Don't repeat yourself" saves a human from editing fifty files; an agent would cheerfully edit fifty files - what it can't do is be sure it found all fifty. The abstraction that protects human memory actively erodes agent reliability.
Regions optimize for exactly the constraints agents have. The whole truth of what a page renders lives in one file - no hops, nothing to reconstruct from files that each tell half the story. And correctness is machine-checkable: validate catches drift from the source template by construction, so the agent doesn't have to remember the contract - it gets told the moment it breaks one. What made components necessary - human memory - is the one thing an agent lacks; what an agent needs - local truth and verifiable correctness - is exactly what a region hands it.
So the abstraction is dying, not failing. Components aren't wrong. They are optimized for a reader who is being replaced at the keyboard. As agents become the primary authors and editors of UI, the justification for referencing UI through a pre-designed API quietly evaporates - not because the API is bad, but because the constraint it managed is no longer the one that governs the work. That is what "components are dead" means: not that the boundary never has a use, but that it has lost its claim to be the default.
What dies is the bundle, not the reuse. "Components are dead" is a claim about one thing: the component as a markup unit you reference through an API. Logic reuse doesn't go anywhere. A region still binds to a headless component - a logic-only unit you write once and reference from every page that uses the region - so a card's behavior lives in exactly one place, the way it always did. What flattens into the page is the markup; what stays referenced, shared, and DRY is the logic. This is exactly React's own headless UI pattern - useButton, useSelect, a custom hook that returns state and handlers but renders nothing - where the reusable part is the behavior and you bring your own markup. A Jay headless component is that pattern made the unit of reuse: the behavior lives in one shared file, the markup lives locally in the page. We're killing the component as a bundle of markup-plus-logic-behind-an-API - not the idea of writing behavior once.
We'll keep this honest on both sides. Regions serve humans too - locality is a human win as much as an agent one, which is why this isn't a grim trade-off but a better default for everyone. And a real component boundary still earns its place where you genuinely need hard isolation, or a one-off, heavily-interactive widget (see the trade-offs below). "Dead" is a claim about the default, and about the reason for it - not a decree that the boundary never has a use.
Two systems, same two problems
A component system and a region system are both trying to solve the same two problems:
- Logic encapsulation - write the behavior of a thing (a card, a gallery, a button) once, and reuse it.
- A design system - ship a consistent, blessed set of UI elements that pages assemble from, rather than re-inventing markup page by page.
The classic component system solves both by making the component the unit you reference. You import <Card>, you pass it props, and its markup lives somewhere else - in the component's own file. That indirection is the whole model, and it has a cost that shows up the moment a consumer wants something the component's author didn't plan for.
Here is the model in miniature. A card component, defined once:
// card.tsx - the definition
function Card({ title, body, imageSrc, imageAlt }: CardProps) {
return (
<div className="card">
<img className="card__media" src={imageSrc} alt={imageAlt} />
<h3 className="card__title">{title}</h3>
<p className="card__body">{body}</p>
</div>
);
}
// page.tsx - a call site
<Card
title="Today"
body="Everything you need."
imageSrc="/hero.png"
imageAlt="Our hero"
/>
The markup is in card.tsx; the data is in page.tsx. To know what this card renders, you read both.
The composition problem - and why slots don't close it
The tidy example varies in four ways. Real design-system components vary in dozens, and a mature component library has good answers for most of them: design tokens and CSS variables for theming, CSS Modules or Tailwind for styling, variant enums for blessed options, and - the big one - slots (children, named slots, render props) so a consumer can project their own markup into a hole the author left open. We are not claiming React can't express "a card with a custom footer." It obviously can. A naive "15 design props" card is a strawman, and the real contrast isn't props-vs-regions - it's where the composition gets resolved.
Slots are the honest comparison, so take them seriously. A slot says: the author leaves a hole, the consumer fills it with a subtree, and the framework keeps that relationship alive at runtime. That last clause is the cost. The projected subtree is authored in the consumer's scope but rendered in the author's tree, so the runtime has to carry the seam: the child's content needs its own reactive context, data from the parent has to reach it without re-rendering the world, refs have to cross the boundary without leaking, and nested slots compose those seams several levels deep. Jay's own component composition was built exactly this way first, and [DL#181, DL#193, DL#194] - weeks of design - are largely the record of making slot projection correct across deep hierarchies: how a parent's data reaches a child's slot, how slot content keeps its own reactive context, how overrides compose through nested components, how refs cross a boundary without leaking. None of that is the feature. It's the bookkeeping a runtime composition boundary forces on you.
Consider the humblest UI element: a button label. Most component systems start with label: string. But real-world design rarely stays plain text - a product manager asks for a bold word, an inline SVG icon, or a dynamic badge. The component author faces a classic dilemma: either add an ever-growing list of styling props (leftIcon, highlightColor, badgeText), or convert label into a slot. The moment it becomes a slot, a trivial styling request forces the runtime to manage a component-slot boundary, tracking reactivity context and node references just to render styled inline text.
A region bypasses that trade-off completely. Because the markup is flattened into the page, the consumer edits the button's inner HTML directly - adding formatting, icons as SVG or images, or any other inline HTML - without touching an API or creating a single runtime composition seam.
So the real question isn't "props or slots?" It's: does the consumer's variation have to become a runtime relationship at all? For data and behavior, yes - that's genuinely dynamic. For appearance and structure - the footer, the ribbon, the reordered fields - a region says no. It resolves that composition at build time and hands the runtime plain markup.
What a region is
A region flattens a design-system template into the consuming file. Instead of referencing a <Card> and passing props, the page pulls the card's markup in as a <jay:card> region - the card template's markup, bindings, and CSS are flattened straight into the page (by the editor, agent, or sync - not the compiler). The page now owns the copy.
The same card, as a region the page owns:
<!-- page.jay-html - the markup IS here, flattened in -->
<jay:card ref="hero">
<div class="card">
<img class="card__media" src="/hero.png" alt="Our hero" />
<h3 class="card__title">Today</h3>
<p class="card__body">Everything you need.</p>
</div>
</jay:card>
There is no prop list and no second file to open for the appearance. A consumer who wants a custom footer doesn't fill a slot - they just type the footer markup into the region, right here. What you see is what renders.
How Jay implements a region
A region isn't inert copied markup - in Jay it stays wired to a real component. The page imports the component with a contract= + src= + template= marker: template= is the markup that gets flattened in, src= is the live connection to the component's code, and contract= is the typed contract between them:
<script
type="application/jay-headless"
contract="card"
src="./node_modules/@my-org/ui/lib/components/card/card.ts"
template="./node_modules/@my-org/ui/lib/components/card/card.jay-html"
></script>
<jay:card ref="hero" productId="sku-42">
<!-- flattened markup, owned and edited by the page -->
</jay:card>
So the split is deliberate: design and structure become local markup; data and logic stay props. Appearance that used to sprawl across the component API now lives in the page as editable markup; but anything the component's code needs - productId, a selected variant, an interaction target - is still passed on the <jay:card> tag and still flows through the imported component. The region's bindings ({price}, ref="addToCart") resolve against that component exactly as a normal component's would. You get locality for the design without losing the headless logic, the contract, or the three-phase rendering behind it.
It hydrates into live, fine-grained reactive UI - not static HTML
The most important thing to be clear about: a flattened region is not a frozen blob of server HTML that the client can only replace wholesale. It hydrates into the same fine-grained reactive UI any Jay component does.
Jay has no virtual DOM and does no tree diffing. Each dynamic binding compiles to a closure that holds a direct reference to one DOM node and writes only that node when its value changes - a text node via adoptText, an attribute/property/style via adoptBase, each guarded by a value-changed check before it touches the DOM. During hydration these adopt* calls reconnect to the server-rendered nodes (resolving each by coordinate) instead of recreating them. A <jay:card> region compiles to its own headless-component instance with its own reactive context: when productId or an internal signal changes, a single reaction recomputes the card's ViewState and pushes it through a flat list of node-level update closures. Only the specific nodes whose computed value actually changed get written. The flattened subtree is never re-rendered or recreated.
One honest nuance: client bindings are phase-gated. Only values marked fast + interactive get client-side adoption; slow- and fast-only values are resolved during SSR and left as inert HTML (nothing to update, so nothing is shipped to update it). So a region is a mix - interactive values are live node-level bindings, everything else is static server HTML - which is a feature, not a limitation: you only pay client reactivity for the parts that are actually reactive.
Keeping the copy honest
A copy drifting from its source is the oldest problem in software, and flattening makes copies on purpose. The region system answers that with two operations:
-
validatereports drift: where a flattened region has deviated from its source template without being marked as a deliberate override. It runs at build time and emits named warnings - likeREGION-NOT-LINKEDandREGION-OVERRIDE-NON-CONTENT- with instructions for the agent on how to fix. -
syncre-flattens every region from its source, preserving the edits the author marked as theirs and overwriting everything else.
So the copy is real, but it is managed. validate keeps it honest; sync keeps it current. The source template is still the design system; the region is just where that design lands in a page.
Marking what you own: override vs jay-content
For sync to know what to keep, an edit has to be marked - from either side. This is the single concrete comparison the two mechanisms deserve:
<!-- CONSUMER side - `override`, placed per instance on the PAGE. -->
<!-- "I changed this; keep it through the next sync." -->
<jay:card ref="hero">
<div class="card">
<h3 class="card__title">Today</h3>
<!-- I rewrote the body and want to own it; the rest stays source-managed -->
<p class="card__body" override="children">Everything you need, <strong>today</strong>.</p>
</div>
</jay:card>
<!-- AUTHOR side - `jay-content`, placed once on the SOURCE template. -->
<!-- "This node is the consumer's to fill; editing it is never drift." -->
<div class="card">
<h3 class="card__title">{title}</h3>
<p class="card__body" jay-content>placeholder body</p> <!-- consumers fill this freely -->
</div>
Both are the same idea - mark who owns a facet - approached from the consumer side and the author side. override is per-instance and reactive to what a given page actually did; jay-content is declared once and applies to every consumer, so they edit with no per-instance mark and validate never calls it drift. Both take the same facet grammar: own one facet (override="style.color"), several (override="class children"), or the whole node. Neither requires pre-designing an override prop - the markup is already there to edit; the mark just records ownership. And unlike slot syntax, jay-content is genuinely optional: you don't have to annotate every variable node, because an unmarked edit simply surfaces as drift until someone marks it or syncs.
What sync does when the source changes structurally
This is the hard case, and it deserves a straight answer rather than a wave. Overrides are anchored structurally - by where a node sits in its parent's child sequence, re-resolved against the current source on every sync (there is no stored merge base). That has a precise, predictable consequence when an upstream design-system author changes the template:
-
Attribute-level change, same tag (the author restyles
<p class="card__body">, you holdoverride="class"): structure still aligns, so your owned facet is carried forward. The override survives. -
Structural change - the author deletes or renames a node at the same sibling level as your override: the child sequence no longer aligns, so
synctakes the source for that subtree. Your local edit is reverted to source, not silently merged into a corrupt hybrid. The one thingsyncwill never do is produce a Frankenstein node that is half your edit and half the new source - alignment is all-or-nothing per sibling sequence.
The safety net is that this divergence is visible at build time: validate reports it as a parent-level "children changed" drift warning before you sync, so a CI gate catches "the upstream template moved under your override" rather than letting it land quietly. The failure mode is "edit reverts, build warns," not "edit silently corrupts" - which is the property that actually makes sync safe to run across a 20-dev repo.
On that team-scale worry directly: validate/sync don't create merge conflicts, they move them earlier and make them legible. The source template is one file owned by the design-system author; each page owns its own flattened copy. Two page authors editing two pages never collide, the way two call sites of a shared <Card> never collide. The new interaction is upstream-template-vs-page, and it resolves as a build-time drift report on exactly the pages affected - a bounded, reviewable list, not a silent runtime surprise.
What regions add over components
If a region is "a component, but resolved at build time," what do you actually gain? Three things - and the third is the thesis from the top.
1. Locality of concerns (LoC)
In a component system, a single visible card is spread across at least two places: the component definition (its markup and bindings) and every call site (the props, slots, and projected children that drive it). Want to understand what this particular card on this particular page renders? You read the page, jump to the component, hold both in your head - and if the variation flows through slots and tokens, you're reconstructing the final tree from files that each tell half the story.
In a region system, the card's markup, its bindings, and any per-instance edits all live in the same file - the page. What you see is what renders. The design system still exists as the source template, but the instance is local and complete.
Locality is where the human and agent cases converge. For a human it means less to hold in your head; for an agent it means the whole truth fits in the window, with nothing hiding behind an API hop - the argument from the top of the post, showing up here as its most concrete, measurable win.
The fair objection is that this cuts both ways: if editing local markup is easy, an agent (or a human) can "fix" a card on page A and quietly fork it from the design system - and growing context windows don't remove that temptation, they just make reading the whole system possible, not mandatory. We think this is exactly where a region system is stronger than the alternatives, not weaker, because the drift is detectable by construction. In a component system, a one-off fork is a new file or a new prop that looks intentional and no tool flags. In a region system, that same local edit is, by definition, a deviation from a named source template - so validate sees it, classifies it (content vs. structure), and either the author marks it as owned or sync reconciles it. The design system doesn't rely on everyone resisting the easy edit; it relies on the build catching the ones that mattered. Easy to edit, and impossible to drift silently.
2. Flexibility without pre-planning
In a component system, every variation a consumer might want has to be anticipated - as a prop, a slot, a token, a variant. Want to add a ribbon to one card? Someone has to expose a ribbon? prop, a slot, or a decorations array and thread it through. Multiply by every consumer and every tweak, and the API becomes a museum of every variation anyone ever needed. The alternative - forking the component - gives up the design-system link entirely.
A region has no override API to pre-design. The consumer edits the flattened copy directly - change the text, add a <strong>, drop in a ribbon - and validate/sync keep that edit honest against the source. You don't predict the future to let a consumer deviate from it. And because validate can classify an edit, the design system can still nudge: "that ribbon is a new layout - maybe it wants to be a second card template" (the REGION-OVERRIDE-NON-CONTENT warning of [DL#200], with [DL#205] providing the second-template mechanism), without ever having blocked the edit.
3. A simpler runtime - the complexity is spent once
This is the thesis. Runtime composition is where UI frameworks spend their complexity, and they spend it on every render. Composing a parameterized child in React means passing functions as props - a render prop, an onSelect, slot-as-children - and wrapping the subtree in context providers. Now you're managing instance identity: wrap callbacks in useCallback with the right deps or the child re-renders and effects re-fire; hold shared state in a useContext whose provider sits at the correct level; make sure a captured closure doesn't pin a stale value or keep a subtree from being collected. None of that is the feature - it's the bookkeeping the runtime boundary forces on you, re-paid every time the tree renders.
Regions don't make the boundary disappear - a <jay:card> still compiles to a headless-component instance with its own reactive context. What changes is how much structure the runtime has to carry. The compiler resolves the already-flattened design - nested regions, per-instance overrides - into a small, fixed set of runtime primitives before the page runs. The runtime never sees "a slot being projected into from two levels up"; it sees plain node-level bindings and a handful of component calls the compiler already resolved. The complexity is spent once, at build time, by code that can see the whole structure - instead of every render, by a runtime reconstructing relationships from instances and callbacks. That's the quiet win: not no boundary, but a runtime with few enough primitives that the instance-and-reference bookkeeping has nowhere to go wrong.
The trade-offs we chose
Two, and we'd rather state them plainly than bury them.
Duplication on the wire
Regions duplicate. A page that flattens five cards contains five copies of the card markup, and the page file grows with everything on it. That's real, and it's exactly why validate and sync exist. Three things bound the cost:
- HTML and CSS parse far faster than JavaScript parses and runs. A component system that renders 100 buttons ships one button's markup but pays in JS: parse the component, run it 100 times, reconcile. A region system ships 100 explicit buttons of HTML - more bytes on the wire - but the HTML parser eats markup at a rate the JS engine can't match, and you skip the framework runtime for those nodes entirely.
- The size is capped by the DOM. The duplicated markup is bounded by the DOM the browser was going to materialize anyway. A component system's "small" source expands to that same DOM at runtime; the region system pays for it up front, in a cheaper medium.
- It compresses. Repeated markup is the ideal case for gzip/brotli; N copies of one template cost far less than N× on the wire.
Where's the tipping point? Honestly: it depends on the ratio of duplicated static markup to unique interactive logic, and we haven't published a 500-region-vs-500-vdom-node benchmark - so we're not going to assert a universal "regions always win." The regime a region system is built for is the design-system-driven, content-heavy page: many instances of a few blessed templates, mostly static, some interactive islands. There, compression collapses the duplication and the parse-speed advantage dominates. The regime where it loses is the opposite - few instances of highly unique, heavily interactive widgets, where there's little to duplicate and the per-instance logic is the bulk anyway. Pick the model that matches your page; regions are not a universal replacement for components, they're the right default for the design-system case.
Light-DOM CSS: isolation of selectors, not of inheritance
A region's CSS is scoped with the native @scope at-rule - @scope (.<ref>) to (.<child-region-refs>) { … } - where .<ref> is a synthetic class on a display:contents wrapper around the region body and the to (...) "donut" limit ([DL#203]) stops the region's selectors at the boundary of each nested child region. That gives structural isolation of the component's own selectors: a parent's .card p can't reach into a nested <jay:badge>, and validate enforces that nested regions carry a ref so the boundary can be named.
What @scope does not do - and we don't paper over it - is create an inheritance boundary. Flattening is light DOM, not Shadow DOM: there's no attachShadow, no :host, no injected reset. So inherited and cascaded properties - color, font-family/font-size/line-height, box-sizing, text spacing - and the entire layout-parent context (the flex/grid container, gap, available inline width, container-query context) cascade in from the host page, exactly as for any ordinary page node. Compared to Shadow DOM, that's a real trade: you give up hard encapsulation of inheritance. We take it on purpose, because for a design system it's usually what you want - global tokens, page typography, and spacing flow into the flattened template naturally, with none of the SSR friction and style-plumbing that shadow boundaries impose. @scope isolates the thing that actually bites in practice (selector bleed across region boundaries); host-page inheritance flowing in is the feature, not the bug. If a template genuinely needs hard isolation, that's a case for a real component boundary, not a region.
The one-line takeaway
A component system makes you reference UI through an API you design in advance, and pays to reconstruct the composition on every render. A region system lets you own UI locally, resolves the composition once at build time, and keeps the copy honest against its source. One optimizes for a single canonical definition a human holds in their head; the other optimizes for whoever - increasingly, whatever - is reading and editing the page with the whole truth in front of them. Components were the right abstraction for the human era of software. We're betting the next one belongs to regions.
來源:DEV Community · dev.to