Index · Previous: Web components · Next: Internationalisation
Some component libraries are written for React and nothing else. MUI, Radix,
Ant, react-select: they need React’s reconciler to run, so
@firsthandjs/react mounts one.
npm install @firsthandjs/react react react-dom
import '@firsthandjs/react/auto'; // once, at startup
import Button from '@mui/material/Button';
<Button variant="contained" onClick={save}>
Save
</Button>;
That import installs an adapter for element types Firsthand does not own, and teaches TSX about React’s component type. Nothing else is needed for a component whose children are text, a number, or nothing at all.
import { fromReact } from '@firsthandjs/react';
import MuiCard from '@mui/material/Card';
const Card = fromReact(MuiCard, { host: 'div' });
<Card>
<MyFirsthandComponent /> {/* Firsthand children, inside a React component */}
</Card>;
A directly placed React component types its children as React’s ReactNode,
and a Firsthand element is not one. fromReact types them as a View, which is
the case it exists for; it also takes the bridge’s own host and class
props, which a direct element has nowhere to put.
It is the same bridge either way — created once per component type — so
neither form is cheaper, and neither hides anything. Without the auto import,
writing a React component as an element throws FirsthandComponentError, which
says which of the two to do.
integrations/interop drives MUI’s Button,
Chip, Slider and a directly placed Alert in Chromium — both forms on one
page, in a real browser, on every run of its npm test.
Each bridge mounts its own React root, so React context does not flow from
one bridged component to another: a <ThemeProvider> rendered through one
bridge cannot reach a <Button> rendered through another. Providers therefore
go around every root:
import { createElement } from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { setReactWrapper } from '@firsthandjs/react';
setReactWrapper((node) => createElement(ThemeProvider, { theme: muiTheme(theme.value) }, node));
Reading theme.value inside the wrapper is an ordinary reactive read, so one
switch restyles every bridged component — and nothing that is not one.
integrations/interop asserts exactly that: its
theme switch lives outside React, and the MUI surfaces follow it.
The same applies to every React context an application relies on: a query client, an i18n provider, a router of React’s own. They belong in the wrapper.
A bridge brings React and react-dom with it — about 45 kB gzip, against 5.87 kB for this runtime — and below the bridge the React model applies: its reconciler, its re-renders, its synthetic events. That is not a flaw; it is what you are asking for when you use a React component, and it works exactly as it does in a React application. The bridge is fine-grained on the Firsthand side only: one effect re-renders the React root when a prop it reads changes.
Worth it for a component you would not want to write again; less obviously worth it for something small:
| You want | A reasonable choice |
|---|---|
| A button, a card, a layout | Elements, or @firsthandjs/styled |
| A whole design system | A web-component one needs no bridge |
| A date picker, a data grid, a rich text editor that exists only for React | This package |
If React is already in your bundle for other reasons, the first row matters much less — the 45 kB is paid either way.
Call fromReact once, at module level. Each instance mounts its own
React root, so creating the bridge inside a component would create a root per
instance of the surrounding page. Keeping the declared bridges in one file also
makes it obvious where React contains Firsthand — that file becomes the list of
those places. A direct element needs none of this: the adapter caches one
bridge per component type for you.
Props are forwarded, except two. class goes on the host element, and
host chooses the host tag (span by default, so inline layout survives).
Everything else reaches the React component untouched, including functions, so
callbacks work in both directions.
Children stay Firsthand’s. React cannot render DOM nodes, so the bridge gives Firsthand its own element inside the React tree and fills it through the ordinary insert. Reactive children update without React hearing about it:
<MuiCard>
<p>{count.value}</p> {/* a text write, not a React render */}
</MuiCard>
Disposal unmounts the root, in a microtask — React refuses to unmount a root while it is rendering, and a Firsthand disposal can happen inside an effect a React event started.
MUI’s props are React’s, so their signatures are React’s:
<MuiTextField
value={text.value}
onChange={(event: { target: { value: string } }) => (text.value = event.target.value)}
/>
<MuiSwitch checked={on.value} onChange={(_event: unknown, next: boolean) => (on.value = next)} />
Writing a signal from a React handler is ordinary: the write flushes synchronously, the Firsthand parts update, and the React subtree re-renders only if one of its props changed.
<ReactHost component={whichever} props= host="div" />
It mounts a root per instance either way, so prefer fromReact where the
component is known.
Linking the package from a monorepo rather than installing it can give your bundler two copies of React and a null hooks dispatcher:
TypeError: Cannot read properties of null (reading 'useContext')
// vite.config.ts
resolve: {
dedupe: ['react', 'react-dom'];
}
An installed package has one copy and needs none of this.
It is not a React compatibility layer. Firsthand does not implement useState
and does not run React components without React — it runs them with React,
which is the only way to get React’s semantics exactly right. Reimplementing
them would mean maintaining a second, less complete React, and your components
deserve the real one. The reasoning is in
ADR-0016.
Next: Internationalisation.