Turn React components into Tuvix.js micro apps, and host any micro app inside a React tree with SSR hydration.
Part of the Tuvix.js microfrontend framework.
npm install @tuvix.js/react react react-dom
# or pnpm add @tuvix.js/react react react-domRequires React 18 or newer.
import { createReactMicroApp } from '@tuvix.js/react';
function Dashboard({ userId }: { userId: string }) {
return <h1>Dashboard for {userId}</h1>;
}
export default createReactMicroApp({ name: 'dashboard', App: Dashboard });The module registers itself under 'dashboard' through registerModule from @tuvix.js/loader, so the orchestrator finds it after the script loads. Give the orchestrator an entry such as () => import('./dashboard'), a container and props.
function createReactMicroApp<P extends object = Record<string, unknown>>(
config: ReactMicroAppConfig<P>
): MicroAppModule;| Option | Type | Default | Meaning |
|---|---|---|---|
name |
string |
required | Registry name; lifecycle events carry it. |
App |
ComponentType<P> |
required | Root component. Receives the mount props; required props type-check. |
bootstrap |
() => void | Promise<void> |
none | One-time setup. |
ssr |
boolean |
false |
Hydrate with hydrateRoot when the container holds server markup (elements or non-whitespace text). |
- Each container gets its own React root, so one module can mount in several places.
mount({ container, props })resolves after the first commit and dispatchestuvix:mountedwithhydrated.update({ props, container })re-renders one container, or all of them withoutcontainer. Component state survives.unmount({ container })unmounts the root, empties the container and dispatchestuvix:unmounted.react-dom/clientloads lazily insidemount, so server bundles can import the module.
createReactMicroApp({ ...config, ssr: true }). Options: name, App, bootstrap. It renders the same tree as TuvixReactApp, so it hydrates TuvixReactApp markup without a mismatch.
Hosts any micro app module (Vue, Svelte, Angular, React or a hand-written { mount, unmount, update? }) inside a React tree.
| Prop | Type | Default | Meaning |
|---|---|---|---|
name |
string |
required | Written to data-tuvix-app, passed to mount. |
app |
MicroAppModule |
required | The module to mount. |
props |
Record<string, unknown> |
{} |
Compared shallowly; a change calls app.update({ props, container }), or remounts when the module has no update. |
ssrHtml |
string |
none | Server-rendered HTML of the island. Pass it on the server only. |
fallback |
ReactNode |
null |
Rendered when mount or update rejects. |
as |
keyof JSX.IntrinsicElements |
'div' |
Wrapper tag. |
className |
string |
none | Wrapper class. |
A hand-written <div dangerouslySetInnerHTML={{ __html }} /> bridge loses the micro app's DOM and listeners on the next parent re-render, because React assigns innerHTML again for each new { __html } object. TuvixIsland creates that object once. During hydration it finds its element through a data-tuvix-island attribute (from useId) and reads the HTML back from the page, so the client does not need ssrHtml. A new name or app remounts; removal calls app.unmount. Calls run in order, so an unmount waits for a pending mount.
// Layout.tsx, rendered on the server with footerHtml and on the client without it
import { TuvixIsland } from '@tuvix.js/react';
import { createSsrVueMicroApp } from '@tuvix.js/vue';
import Footer from './Footer.vue';
const footer = createSsrVueMicroApp({ name: 'footer', App: Footer });
export function Layout({ year, footerHtml }: { year: number; footerHtml?: string }) {
return (
<TuvixIsland name="footer" app={footer} props={{ year }} ssrHtml={footerHtml} fallback={<footer>© {year}</footer>} />
);
}// entry-server.tsx
import { renderToString } from 'react-dom/server';
import { renderVueToString } from '@tuvix.js/vue/server';
import { Layout } from './Layout';
import Footer from './Footer.vue';
export async function renderPage(): Promise<string> {
const footerHtml = await renderVueToString(Footer, { year: 2026 });
return renderToString(<Layout year={2026} footerHtml={footerHtml} />);
}// entry-client.tsx
import { hydrateRoot } from 'react-dom/client';
import { Layout } from './Layout';
hydrateRoot(document.getElementById('root')!, <Layout year={2026} />);Renders a React micro app inline in the host tree inside <div data-tuvix-app={name}>.
| Prop | Type | Default | Meaning |
|---|---|---|---|
name |
string |
required | Must match the module's name. |
App |
ComponentType<P> |
required | Component to render. |
fallback |
ReactNode |
null |
Shown while App suspends or after it throws. |
| other props | P |
required by P |
Passed to App. |
App sits in an error boundary and a <Suspense fallback>. A server render error replaces only this app with fallback and React retries it on the client. A client render error shows fallback and dispatches tuvix:error. It dispatches tuvix:mounted (with hydrated) and tuvix:unmounted.
Subscribes in an effect and unsubscribes on unmount. The latest handler is kept in a ref, so it does not resubscribe each render. Pass getGlobalBus() from @tuvix.js/event-bus; the adapter does not hand the orchestrator bus to App.
Returns props that merge each payload of updateEvent (default 'tuvix:props:update') on bus. Nothing in Tuvix emits that event; you emit it. Modules from createReactMicroApp receive new props through update without this hook.
ReactMicroAppConfig<P>, TuvixIslandProps, TuvixReactAppProps<P>.
- A throw in the first render rejects
mount, empties the container and dispatchestuvix:error. A later throw blanks the app until you remount it. unmountduring a pendingmountwaits for it and leaves the container empty.mounton a container the module already holds unmounts the old root (dispatchingtuvix:unmounted) and renders fresh with the new props, like the Vue, Svelte and Angular adapters.unmountandupdateon an unknown container do nothing.- When React removes a
TuvixIsland, the module reportstuvix:unmountedafter the element is detached. The event goes todocument(withdetail.containerset), sodocumentlisteners andonLifecyclereceive it; listeners on the container or its ancestors do not.isMounted(name)turnsfalseonce no container of the app is mounted. - Under
<StrictMode>in development React runs the island's mount effect twice, but the island skips the discarded first run: the module mounts once and adopts the server markup. - After a failed mount, a new
appornamemounts into an empty element. It never receives the failed app's server HTML.
- Load modules by URL. Use
@tuvix.js/coreor@tuvix.js/loader. - Render to a string. Use
react-dom/server, orrenderVueToStringand friends forssrHtml. - Load island CSS. Use
@tuvix.js/vite.
TuvixReactAppserver HTML now has Suspense comment markers; upgrade server and client together.mountrejects when the first render throws.unmounton a container the module never mounted does nothing.
Full documentation: tuvix.js.org/packages/react
MIT