React
A published component becomes an idiomatic React component in one call:
import { adaptReact } from 'lemonadejs/react';
import { Switch } from './switch';
const ReactSwitch = adaptReact(Switch);
// inside any React 18 app: props, value/onChange, refs, all native:
<ReactSwitch label="Dark mode" value={on} onChange={setOn} ref={api} />
Nothing in the adapter knows about Switch specifically. Everything,
which props exist, how value/onChange map, which events fire, what the
ref exposes, is derived from the component’s
contract. One generic adapter replaces every
hand-written per-plugin wrapper.
How the adapter works
adaptReact() is not a wrapper you write; it is a wrapper derived.
The contract declares props, bind, events and api; the adapter maps each
one to the React convention (src/react.ts, ~150 lines, and you can read
all of it):
- Declared props become adapter-owned states. Created once; on every
React render the adapter diffs the incoming props into them. Updates
flow into the component as live state changes, with no remount. The
test suite pins this: after a React
setStatechanges a prop, the component’s DOM element is the same instance, updated in place. bindmaps tovalue/onChange. A component-initiated commit (thebind()tool’s.set()) firesonChange; a controlled write from React updates the component without echoingonChangeback. If the contract declares its ownvalueprop (form-submit values), React’svaluegoes there instead andbindstays internal.- Declared events call the latest callback. Handlers read the current
props through a ref, so the stale-closure class is closed. Casing is
honored per side: the component declares
onchange(our rule, all lowercase); the React consumer may writeonChange(their convention). - The declared api flows through the React ref. A
useImperativeHandlefacade delegates to the live instance:apiRef.current.toggle(): the v5-style imperative surface, typed by the contract. - StrictMode-safe. Mounting is effect-scoped and unmount is idempotent: mount → unmount → mount leaves exactly one live, reactive instance.
- No contract? Still adapts. Props are passed as a mount-time snapshot, which is v5 behavior, fine for static embeds.
- The host is a single
<div style="display:contents">, so the adapter adds no box to your layout. React is an optional peer dependency (>=17), and thelemonadejs/reactentry is a satellite build that shares the one engine indist, with no second copy.
The verification matters as much as the mechanics: the adapter is tested
against real React 18 (react-dom/client, createRoot, act,
StrictMode), not against an imitation of React’s behavior
(tests/react.test.ts). The StrictMode test asserts exactly one instance
survives the double-mount and stays interactive after it.
adaptReact is client-side interop: React owns the slot, the component
owns its subtree. It is not a server-component or SSR story.
v6 components are portable assets. Write the component once against the contract; deploy it as a lemonade component, a React component, or a custom element: the same artifact, three doors (see Deployments).
In practice
The component and its React life, end to end, the shape the adapter’s own test suite exercises:
// switch.ts, the component, written once
import { html, component } from 'lemonadejs';
export const Switch = component('switch', {
bind: false,
label: '',
onchange: Function,
api: { toggle: Function },
}, (props, { bind }) => {
const checked = bind(props, false);
const toggle = () => checked.set(!checked.value);
props.ref?.({ toggle });
return html`<div class="switch ${() => (checked.value ? 'on' : 'off')}"
onclick="${toggle}">${props.label}</div>`;
});
// app.jsx, the React consumer, nothing foreign in sight
import { useRef, useState } from 'react';
import { adaptReact } from 'lemonadejs/react';
import { Switch } from './switch';
const ReactSwitch = adaptReact(Switch);
export const Settings = () => {
const [dark, setDark] = useState(false);
const api = useRef(null);
return <>
<ReactSwitch label="Dark mode" value={dark} onChange={setDark} ref={api} />
<button onClick={() => api.current.toggle()}>Toggle from React</button>
</>;
};
The other direction: lemonade state inside React. React components
can subscribe to a store without adopting the renderer;
subscribe/peek are a ready-made useSyncExternalStore pair:
const value = useSyncExternalStore(rows.subscribe, rows.peek);
The island, for big data. When the point is to escape React’s re-render cost rather than join it, mount directly and let the island’s bindings write the DOM without React knowing:
function GridPanel({ data }) {
const rows = useMemo(() => store(data), []);
const ref = useRef();
useEffect(() => {
const app = mount(Grid, ref.current, { rows });
return () => app.unmount();
}, []);
useEffect(() => { rows.value = data; }, [data]);
return <div ref={ref} />;
}
A cell edit inside the island is rows.value[i][j] = v; rows.touch(),
React does not re-render, reconcile, or know. States are the
React→island bridge; callbacks are the island→React bridge.
Reference
import { adaptReact } from 'lemonadejs/react'; // react >=17, optional peer dep
const ReactC = adaptReact(C); // ForwardRefExoticComponent: use like any component
<ReactC
label="x" // declared prop, diffed into a live state every render
value={v} // bind, by React convention (unless the contract
onChange={fn} // declares its own `value` prop)
onsave={fn} // declared events; onSave casing also accepted
ref={api} // api.current.method(): the declared api, nothing else
/>
- Host element: one
<div style="display:contents">; the component owns the subtree. - Without a contract: props are a mount-time snapshot (v5 behavior).
- StrictMode-safe; React unmount calls the full destroy path.
- For every other host, the same contract derives a custom element: Web components.