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 setState changes a prop, the component’s DOM element is the same instance, updated in place.
  • bind maps to value/onChange. A component-initiated commit (the bind() tool’s .set()) fires onChange; a controlled write from React updates the component without echoing onChange back. If the contract declares its own value prop (form-submit values), React’s value goes there instead and bind stays 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 write onChange (their convention).
  • The declared api flows through the React ref. A useImperativeHandle facade 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 the lemonadejs/react entry is a satellite build that shares the one engine in dist, 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.