firsthand

ADR-0016: Web components first, and a React bridge for the rest

Status: accepted (2026-09-19)

Problem

“Can I use MUI?” is the question that decides whether a framework is usable at work. Behind it are three different questions with three different answers, and conflating them is how frameworks end up promising compatibility they cannot deliver:

  1. Can I use a web-component library — Shoelace, Material Web, Fluent, Vaadin, Carbon?
  2. Can I use a React library — MUI, Radix, Ant, react-select?
  3. Can I write components the way styled-components lets me?

Constraints

Chosen design

Web components: nothing at all

A custom element is an element. Firsthand sets properties as properties, forwards attributes, and delegates events. The only thing missing was event names with hyphens: onSlChange lowercases to slchange, and the event is sl-change. No casing of an identifier produces a hyphen.

So on: takes the name verbatim — on:sl-change, on:value-changed — in both the compiler and the runtime. That is the whole of the change, and it makes every web-component library usable with an import and no adapter. JSX permits one colon in an attribute name, so the literal form takes no modifier; onClick:capture remains the way to ask for that.

React: a bridge, priced honestly

@firsthandjs/react is 0.66 kB and mounts a React root per bridged instance. Props flow in through one effect, children stay Firsthand’s (the bridge gives Firsthand an element inside the React tree and fills it through the ordinary insert, because React cannot render DOM nodes and a Firsthand dynamic child is not one), and events call back out.

It works — integrations/interop drives MUI’s Button, Chip and Slider in Chromium. It also brings React itself, about 45 kB gzip against this framework’s 5.87 kB, and everything below the bridge updates the React way. Both facts are in the docs rather than buried, and the table there suggests an element or a web component where one would do, because the bridge is worth its size for a date picker and not for a button.

Styling: its own package

ADR-0015.

Performance implications

Memory implications

One React root and one host element per bridged instance, released on disposal — in a microtask, because React refuses to unmount a root while it is rendering.

DX implications

Rejected alternatives

Update, after using it

Two things were missing once an application was actually built on this.

React context does not cross bridge boundaries. Each bridge mounts its own React root, so a ThemeProvider rendered through one bridge cannot reach a button rendered through another — they are separate trees. MUI therefore kept its default theme while the rest of the page changed. setReactWrapper(fn) wraps every bridged root in the same elements, and because the wrapper is read inside each root’s render effect, a signal read in it makes one theme switch re-render every bridged component and nothing else.

Declaring a bridge per component is noise where it carries no information. @firsthandjs/react/auto makes a React component an ordinary element; it is the same bridge, cached per component type. fromReact remains for the case the types cannot express — a React component wrapping Firsthand children — and for the bridge’s own host and class props. See ADR-0017 for the seam that makes it possible without @firsthandjs/dom naming React.