Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

Blogarchitecture

One core, five adapters: what it costs and what it buys

Why the components are written once as framework-free logic with an adapter per framework, where that plan stopped being true, and what keeps it honest now.

Written by
productdevbook
Published
Length
6 min read

hamur is one set of headless components for React, Vue, Svelte, Solid and Angular. It is not five libraries that happen to look alike. The behavior of a Dialog, a Menu or a Combobox is written once, with no framework in it, and each framework gets an adapter that renders the parts and connects its own reactivity to that logic.

This post is about that decision: what the layers are, what they cost, and what we found when we measured whether the plan was still true.

Three layers

Every line of a component lives in one of three places.

LayerPackageMay touchHolds
core@hamur/coreno DOM, no frameworkstate transitions, prop and data attribute mapping, validation, ids, error messages
dom@hamur/domthe DOMcontrollers with a dispose(), native event handlers, measurement, focus, positioning
adapter@hamur/react, …its frameworkrendering the parts, props and context, lifecycle, reactivity

The rule for placing code is short. If it needs no document, window or element, it goes in core. If it needs the DOM but no framework, it goes in dom. An adapter holds only what is the framework. The design notes say what happens otherwise in one sentence:

An adapter that grows logic is a bug in the plan.

What core asks of a framework

Core cannot import a framework, yet state has to be reactive in all five. So core names the smallest surface it needs and lets each adapter supply it:

core/controllable-state.ts
export interface ReactiveCell<T> {
  get(): T
  set(value: T): void
}

export interface ReactiveHost {
  /** Runs `fn` without tracking what it reads. */
  untrack<T>(fn: () => T): T
  /** Runs `fn` once, when the component that created the host is destroyed. */
  onCleanup(fn: () => void): void
  /** Runs `fn` once, after the framework applied the current update. */
  schedule(fn: () => void): void
  /** A tracked value holding `initial`, written with `set()`. */
  cell<T>(initial: T): ReactiveCell<T>
}

Each adapter implements it once. This is all of Vue's:

vue/internals.ts
export function useReactiveHost(): ReactiveHost {
  return {
    untrack: (fn) => fn(),
    onCleanup(fn) {
      if (getCurrentScope()) {
        onScopeDispose(fn)
      }
    },
    schedule(fn) {
      void nextTick(fn)
    },
    cell(initial) {
      const value = shallowRef(initial)
      return {
        get: () => value.value,
        set(next) {
          value.value = next
        },
      }
    },
  }
}

Angular's has the same shape, built from signal, untracked, DestroyRef and afterNextRender. With a host in hand, the rule every component repeats (a prop that is either controlled by the app or kept inside) is one function in core instead of one per adapter:

ts
const open = createControllableState(host, {
  controlled: () => props.open,
  default: () => props.defaultOpen ?? false,
  name: 'Dialog',
  state: 'open',
})

open.value // the prop while controlled, the internal value otherwise
open.set(true) // does nothing while controlled: the change is reported instead

Where the plan stopped being true

"Thin adapters" was the plan. On 5 November we counted the source lines to see if it was also the code.

  • core and dom together: about 31.7k
  • the Vue adapter: 37.8k
  • the Svelte adapter: 43.5k
  • the Angular adapter: 43.1k
  • the Solid adapter: 45.7k

Each adapter was larger than the shared layer it was meant to sit thinly on. The Combobox root was the clearest case: about 1,600 to 1,700 lines in every adapter, with functions such as setSelectedValue, handleSelection and setIndices that were near line-for-line copies. Only the spelling of reactivity differed between them.

So the logic is going back, in small slices. The Combobox root came first. Its selection, highlight, input value and autofill paths now live in one controller in dom, which every root calls:

Combobox rootBeforeAfter four slicesChange
React1,8701,587−283
Vue1,6801,392−288
Svelte1,5841,311−273
Angular1,7101,441−269
Solid1,6681,364−304

That is 1,417 lines out of five files. The shared file they moved into grew from 555 lines to 1,232, so the same behavior now costs 677 lines, once. The roots are still large, and we are not done.

Moving code is the easy half. Keeping it moved needs a check, because the fastest way to make a failing test pass in one framework is always to write the fix in that framework. A script we call the logic budget counts, per component, the adapter's lines and the patterns that give logic away in adapter source: addEventListener, setTimeout, requestAnimationFrame, event.key. It compares them with a saved baseline and fails when a count rises. Each slice lowers the baseline in its own commit.

What it costs

Frameworks disagree about time. schedule means "after the framework applied the current update". That is nextTick in Vue and afterNextRender in Angular, and the two are not the same moment. Some differences cannot be hidden behind an interface at all. In Angular a controlled value reaches a part with the next change detection pass, so two clicks that arrive back to back would both start from the old value. The Angular adapter keeps the value a handler just set as the value until the next render. That code exists in one adapter only, and it has to.

The shared surface is the smallest one. Vue has no public untrack, so its host calls the function as it is. Core has to be written so that its untracked reads happen where tracking them would be harmless. Every framework's limits become core's limits.

Indirection. A controller cannot close over props the way a component does. It takes a getParams() callback and reads the adapter's latest values when an event runs. That is more ceremony than a function inside a component, and you feel it in every file.

A shared core does not make an adapter fast. Mounting 1,000 Switches in Svelte took 197.4 ms. It takes 74.9 ms since we found a document-wide label lookup running twice per Switch, once on mount and once in an effect. That bug was in the adapter. Rendering cost belongs to each framework, and each one has to be measured on its own.

Everything is verified five times. The React test suite runs on every adapter through a neutral test layer with a JSX shim. Only what that layer cannot run is translated by hand, with the assertion kept. A second tool compares the rendered DOM and the behavior, framework by framework, in Chromium, Firefox and WebKit. And because a test run can pass without ever loading the code you rewrote, a canary puts a deliberate throw into a component's root and requires the run to fail.

What it buys

  1. A fix lands once. A rule discovered while porting one framework goes into the matching core or dom module and reaches every adapter in the same change.
  2. One suite judges five implementations. The same assertions run everywhere, so a difference between frameworks shows up as a failing test before it shows up in someone's app.
  3. Each framework stays itself. Vue gets components that sit well in a single-file component, Svelte gets runes, Angular gets standalone directives with signals, Solid gets function components with signals and JSX. The adapter can afford to be idiomatic because it is not also carrying the behavior.
  4. The fifth adapter started from finished logic. Solid was added after the other four, on components whose logic was already shared. Anything a Solid port found missing went back to dom as a gap, not into Solid.
  5. New components start in core. The QR Code encoder is a pure function in core and gives the same result on the server. The Signature Pad keeps its strokes in core as points and only renders them in the adapter.

Would we do it again

Yes, with one change: the budget would exist from the first commit. The architecture did not keep itself. We wrote down that adapters must stay thin, and they grew anyway, in every framework at once, while the tests kept passing. What holds the line now is a script that fails.

You can see the result side by side on the Compare page, where one Dialog runs in all five frameworks, or start from the components.