インストール
npm install @tuvix.js/react react react-dom
# or pnpm add @tuvix.js/react react react-domこのパッケージにはReact 18以降が必要です(reactとreact-domのピア範囲は>=18.0.0)。@tuvix.js/loaderと@tuvix.js/event-busに依存します。
使用する場面
このパッケージは2つの方向をカバーします。
- マイクロアプリとしてのReact。
createReactMicroAppとcreateSsrReactMicroAppは、Reactコンポーネントをオーケストレーターとローダーが想定する{ bootstrap, mount, unmount, update }モジュールでラップします。Reactアプリが、Tuvixのシェルが読み込むマイクロアプリの1つである場合に使います。 - ホストとしてのReact。
TuvixIslandは任意のマイクロアプリモジュールをReactページ内にマウントし、再レンダリングをまたいでそのDOMを維持します。TuvixReactAppはReactマイクロアプリをホストのツリー内にインラインでレンダリングします。シェルがTanStack Start、Next.js、RemixなどのReact SSRフレームワークである場合に使います。
シェルがReactを使わないプレーンなHTMLであれば、必要なのは最初の2つと@tuvix.js/coreだけです。Vue、Svelte、Angularのマイクロアプリでは、@tuvix.js/vue、@tuvix.js/svelte、@tuvix.js/angularでモジュールを作成します。TuvixIslandはそのいずれもホストできます。
クイックスタート
マイクロアプリのモジュールを作成します。
// src/dashboard.tsx
import { createReactMicroApp } from '@tuvix.js/react';
function Dashboard({ userId }: { userId: string }) {
return <h1>Dashboard for {userId}</h1>;
}
export default createReactMicroApp({ name: 'dashboard', App: Dashboard });シェルに登録します。オーケストレーターはルートが一致するとmountを呼び出し、propsを変更するとupdateを呼び出します。
// src/shell.ts
import { createOrchestrator } from '@tuvix.js/core';
const orchestrator = createOrchestrator();
orchestrator.register({
name: 'dashboard',
entry: () => import('./dashboard'),
container: '#app',
activeWhen: '/dashboard/*',
props: { userId: 'u_42' },
});
await orchestrator.start();
// Re-renders <Dashboard userId="u_43" />; component state survives.
await orchestrator.updateAppProps('dashboard', { userId: 'u_43' });API
createReactMicroApp
function createReactMicroApp<P extends object = Record<string, unknown>>(
config: ReactMicroAppConfig<P>
): MicroAppModule;| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
name | string | 必須 | @tuvix.js/loaderのregisterModuleを通じてモジュールを登録する名前です。ライフサイクルイベントはこの名前を持ちます。 |
App | ComponentType<P> | 必須 | ルートコンポーネントです。マウント時のpropsを自身のpropsとして受け取るため、{ page: 'privacy' | 'terms' }のような必須propsを持つコンポーネントも型チェックを通ります。 |
bootstrap | () => void | Promise<void> | なし | 一度だけ実行するセットアップです。モジュールのbootstrap()はこれをawaitします。 |
ssr | boolean | false | コンテナが要素または空白以外のテキストを含む場合、hydrateRootでサーバーのマークアップをハイドレートします。空白またはコメントのみのコンテナにはcreateRootで新規にレンダリングします。 |
返されるモジュールは4つのメソッドを持ちます。
mount({ container, props })はそのコンテナ用にReactルートを1つ作成します。Reactが最初のレンダリングをコミットした後に解決し、コンテナ上でhydrated: trueまたはfalseを付けてtuvix:mountedをディスパッチします。最初のレンダリングでAppがサスペンドした場合、mountはコンテンツがコミットされるまで待ちます。update({ props, container })はそのコンテナ内でAppをprops付きで再レンダリングします。containerを省略すると、モジュールがマウントしたすべてのコンテナを再レンダリングします。ツリーの形が変わらないため、Reactはコンポーネントの状態を保持します。unmount({ container })はルートをアンマウントし、replaceChildren()でコンテナを空にしてtuvix:unmountedをディスパッチします。bootstrap()はconfig.bootstrapを実行します。
コンテナごとに独自のルートを持つため、1つのモジュールを複数の場所へ同時にマウントできます。
import { createReactMicroApp } from '@tuvix.js/react';
function Badge({ label }: { label: string }) {
return <span className="badge">{label}</span>;
}
const badge = createReactMicroApp({ name: 'badge', App: Badge });
const left = document.createElement('div');
const right = document.createElement('div');
document.body.append(left, right);
await badge.mount({ container: left, props: { label: 'New' } });
await badge.mount({ container: right, props: { label: 'Sale' } });
// Only the right badge changes.
await badge.update?.({ props: { label: 'Sold out' }, container: right });
await badge.unmount({ container: left });モジュールはmountの中でreact-dom/clientを遅延インポートします。サーバーバンドルは、クライアントレンダラーを取り込まずにモジュールをインポートできます(たとえばAppをTuvixReactAppと共有するため)。
createSsrReactMicroApp
function createSsrReactMicroApp<P extends object = Record<string, unknown>>(
config: Omit<ReactMicroAppConfig<P>, 'ssr'>
): MicroAppModule;createReactMicroApp({ ...config, ssr: true })と同じです。オプションは上の表と同じくname、App、bootstrapです。
同じコンポーネントについてサーバーがレンダリングしたHTMLがコンテナにすでにある場合に、クライアントで使います。レンダリングするツリーはTuvixReactAppがレンダリングするもの(エラーバウンダリと<Suspense>)と一致するため、TuvixReactAppのマークアップを不一致なくハイドレートします。
import { createSsrReactMicroApp } from '@tuvix.js/react';
function LegalPage({ page }: { page: 'privacy' | 'terms' }) {
return <article>{page === 'privacy' ? 'Privacy policy' : 'Terms of use'}</article>;
}
export default createSsrReactMicroApp({ name: 'legal-app', App: LegalPage });TuvixIsland
function TuvixIsland(props: TuvixIslandProps): ReactElement;| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
name | string | 必須 | マイクロアプリ名です。アイランドはこれをdata-tuvix-appに書き込み、app.mountに渡します。 |
app | MicroAppModule | 必須 | マウントするモジュールです。createVueMicroApp、createSvelteMicroApp、createAngularMicroApp、createReactMicroApp、それぞれのcreateSsr*版の戻り値、または手書きの{ mount, unmount, update? }オブジェクトです。 |
props | Record<string, unknown> | {} | モジュールに渡すpropsです。アイランドはレンダリングのたびにこれを浅く比較します。 |
ssrHtml | string | なし | アイランドのサーバーレンダリング済みHTMLです(たとえばrenderVueToStringの結果)。サーバー側でのみ渡します。 |
fallback | ReactNode | null | mountまたはupdateがrejectしたとき、アイランドの代わりにレンダリングされます。 |
as | keyof JSX.IntrinsicElements | 'div' | ラッパー要素のタグです。 |
className | string | なし | ラッパー要素のクラス名です。 |
TuvixIslandは、React以外のマイクロアプリ(または別バンドルのReactマイクロアプリ)をReactページ内にホストします。<div dangerouslySetInnerHTML={{ __html: html }} />をレンダリングする手書きのブリッジは、親の最初の再レンダリングで壊れます。Reactは新しい{ __html }オブジェクトを見てinnerHTMLを再代入し、マイクロアプリが付けたDOMとリスナーを破棄するためです。TuvixIslandはこれを回避します。
- サーバーレンダリング。
ssrHtmlを内部HTMLとして持つ<div data-tuvix-app={name} data-tuvix-island={id}>をレンダリングします。idはReactのuseIdから得るため、サーバーとクライアントで同じ値になります。 - ハイドレーション。 クライアントでの最初のレンダリングで、
data-tuvix-islandを使ってページ内の自身の要素を見つけ、内部HTMLをDOMから読み戻します。ssrHtmlをクライアントへ再送する必要はなく、クライアントのレンダリング結果はサーバーのものと一致します。 - 安定したDOM。 アイランドは
{ __html }オブジェクトをuseStateで一度だけ作成します。以降のレンダリングは同じオブジェクトを再利用するため、Reactがアイランドの子要素を書き換えることはありません。親が再レンダリングしても、マイクロアプリのノードとリスナーは保持されます。 - マウント。 最初のコミットの後、アイランドは
app.mount({ container, props, name })を呼び出します。ssr: trueで作成したモジュール(createSsrVueMicroAppなど)はここでサーバーのDOMを引き継ぎます。SSRなしのモジュールはコンテナに新規にレンダリングします。 - props。
propsが浅い比較で変化した場合(キーが異なる、またはObject.isで一致しない値がある)、アイランドはapp.update({ props, container })を呼び出します。updateを持たないモジュールには、unmount、コンテナを空にする処理、新たなmountが順に行われます。同じ値を持つインラインのオブジェクトリテラルでは更新は発生しません。 - 同一性。
nameまたはappが新しくなると、古いモジュールをアンマウントして新しいモジュールをマウントします。 - 削除。 アイランドがツリーから外れると、
app.unmount({ container })を呼び出してコンテナを空にします。 - 順序。 マウント、更新、アンマウントの呼び出しは1つずつ順に実行されます。
mountの保留中に届いたアンマウントは、その完了を待ちます。 - エラー。
mountまたはupdateがrejectすると、アイランドはラッパーの代わりにfallbackをレンダリングします。tuvix:errorはアダプター自身がディスパッチし、アイランドはイベントを追加しません。後でnameまたはappが変わると再試行します。
ReactレイアウトにVueのフッターを入れた、サーバーとクライアントの完全なセットアップです。
// src/Layout.tsx (shared by server and client)
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 (
<main>
<h1>Home</h1>
<TuvixIsland
name="footer"
app={footer}
props={{ year }}
ssrHtml={footerHtml}
fallback={<footer>© {year}</footer>}
as="section"
className="site-footer"
/>
</main>
);
}// src/entry-server.tsx (runs on the server only)
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 year = 2026;
const footerHtml = await renderVueToString(Footer, { year });
const body = renderToString(<Layout year={year} footerHtml={footerHtml} />);
return `<!doctype html><html><body><div id="root">${body}</div><script type="module" src="/src/entry-client.tsx"></script></body></html>`;
}// src/entry-client.tsx
import { hydrateRoot } from 'react-dom/client';
import { Layout } from './Layout';
// No footerHtml here: the island reads the footer HTML from the page.
hydrateRoot(document.getElementById('root')!, <Layout year={2026} />);サーバーのHTMLはJavaScriptより先にブラウザへ届くため、フッターのスタイルシートを<head>に出力しておけば、フッターは最初の描画からスタイル付きで表示されます。@tuvix.js/viteがその<link>タグを提供します。
TuvixReactApp
function TuvixReactApp<P extends object>(props: TuvixReactAppProps<P>): ReactElement;| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
name | string | 必須 | マイクロアプリ名で、data-tuvix-appに書き込まれます。createSsrReactMicroAppに渡すnameと一致させます。 |
App | ComponentType<P> | 必須 | レンダリングするコンポーネントです。 |
fallback | ReactNode | null | Appがサスペンドしている間と、例外を投げた後に表示されます。 |
| その他のプロパティ | P | Pで必須のもの | Appに渡されます。 |
TuvixReactAppはReactマイクロアプリをホストのReactツリーの一部としてインラインでレンダリングするため、そのHTMLはSSRフレームワークがレンダリングします。<div data-tuvix-app={name}>を出力し、Appをエラーバウンダリと<Suspense fallback>でラップします。
- サーバーのエラーは局所化されます。 サーバーレンダリング中に
App内で例外が発生すると、Reactはこのアプリについてのみfallbackをレンダリングします。ページの残りはレンダリングされ、ReactはクライアントでAppを再試行します。 - クライアントのエラーは局所化されます。 エラーバウンダリがクライアントでのレンダリングエラーを捕捉し、
fallbackを表示してラッパー上でtuvix:errorをディスパッチします。 - ライフサイクルイベント。 最初のコミットの後に
tuvix:mountedをディスパッチします。ラッパーがサーバーのHTMLから来た場合、hydratedはtrueです。コンポーネントがツリーから外れると、Reactがラッパーを切り離す前にtuvix:unmountedをディスパッチするため、document上のリスナーもこれを受け取ります。表示済みの<Suspense>バウンダリが再びサスペンドした場合、アプリはunmountedを報告し、コンテンツが戻るとmountedを報告します。
import { TuvixReactApp } from '@tuvix.js/react';
function LegalPage({ page }: { page: 'privacy' | 'terms' }) {
return <article>{page === 'privacy' ? 'Privacy policy' : 'Terms of use'}</article>;
}
export function PrivacyRoute() {
return (
<TuvixReactApp
name="legal-app"
App={LegalPage}
page="privacy"
fallback={<p>This section failed to load.</p>}
/>
);
}Vue、Svelte、Angular、バニラのマイクロアプリをホストするにはTuvixIslandを使います。
useTuvixBus
function useTuvixBus<T = unknown>(
bus: IEventBus,
event: string,
handler: EventHandler<T>
): void;エフェクト内でbusのeventにhandlerを購読し、アンマウント時に購読を解除します。フックは最新のhandlerをrefに保持するため、最新の状態を読むハンドラーでもレンダリングのたびに再購読することはありません。busまたはeventが新しくなると再購読します。購読はクライアントにのみ存在します。サーバーレンダリング中はエフェクトが実行されないためです。
アダプターはオーケストレーターのバスをAppに渡しません。eventBusオプションを指定しない限り、オーケストレーターはgetGlobalBus()を使うので、バスはそこから取得します。
import { useState } from 'react';
import { useTuvixBus } from '@tuvix.js/react';
import { getGlobalBus } from '@tuvix.js/event-bus';
export function CartIcon() {
const [count, setCount] = useState(0);
useTuvixBus<{ items: unknown[] }>(getGlobalBus(), 'cart:updated', ({ items }) => {
setCount(items.length);
});
return <span>{count}</span>;
}useTuvixProps
function useTuvixProps<T extends Record<string, unknown>>(
initialProps: T,
bus?: IEventBus,
updateEvent?: string
): T;| 引数 | 型 | デフォルト | 説明 |
|---|---|---|---|
initialProps | T | 必須 | 初期値です。フックは最初のレンダリングでこれを読み、以降の変更は無視します。 |
bus | IEventBus | なし | リッスンするバスです。省略すると、フックはinitialPropsをそのまま返します。 |
updateEvent | string | 'tuvix:props:update' | このイベントのペイロードを、フックが現在のpropsにマージします({ ...prev, ...payload })。 |
オーケストレーターもアダプターもtuvix:props:updateを発行しません。自分で発行します。createReactMicroAppで作成したモジュールでは、オーケストレーターからのpropsにこのフックは不要です。update({ props })が新しいpropsでAppを再レンダリングします。
import { useTuvixProps } from '@tuvix.js/react';
import { getGlobalBus } from '@tuvix.js/event-bus';
export function ThemeLabel() {
const props = useTuvixProps({ theme: 'light' }, getGlobalBus());
return <p>Theme: {props.theme}</p>;
}
// Elsewhere: getGlobalBus().emit('tuvix:props:update', { theme: 'dark' });型
| 型 | 説明 |
|---|---|
ReactMicroAppConfig<P> | createReactMicroAppの設定:name、App、bootstrap?、ssr?。 |
TuvixIslandProps | TuvixIslandのプロパティ:name、app、props?、ssrHtml?、fallback?、as?、className?。 |
TuvixReactAppProps<P> | TuvixReactAppのプロパティ:name、App、fallback?、およびAppのprops。 |
エッジケース
- 最初のレンダリングで例外が発生した場合。
mountはそのエラーでrejectし、ルートをアンマウントしてコンテナを空にし、tuvix:errorをディスパッチします。0.4ではmountは解決し、エラーはReactのルートエラーハンドラーに届いていました。 - 後のレンダリングで例外が発生した場合。 エラーバウンダリがアプリを空にし、
tuvix:errorをディスパッチします。ホストページは動作を続けます。アプリはアンマウントして再度マウントするまで空のままで、updateでは元に戻りません。 - マウント中のアンマウント。
mountがまだreact-dom/clientを読み込んでいるか最初のコミットを待っている間に届いたunmountは、その完了を待ち、コンテナを空にして、mountedイベントはディスパッチしません。 - 同じコンテナへの2回のマウント。 モジュールがすでに保持しているコンテナへの2回目の
mountは、古いルートをアンマウントしてtuvix:unmountedをディスパッチし、新しいpropsで新規にレンダリングしてtuvix:mountedをディスパッチします。コンポーネントの状態はリセットされます。Vue、Svelte、Angularのアダプターも同じ動作です。状態を保持するにはupdateを呼び出します。 - 未知のコンテナ。 モジュールがマウントしていないコンテナに対する
unmountとupdateは何もしません。 - propsは置き換えられます。 モジュールの
updateは、渡したpropsのみでAppをレンダリングします。orchestrator.updateAppPropsはupdateを呼び出す前に、新しいpropsを登録済みのpropsにマージします。 ssr: trueで空白のみのコンテナ。hasServerMarkupが空白テキストとコメントを無視するため、モジュールは新規にレンダリングします。- Reactによるアイランドの削除。 モジュールが
tuvix:unmountedを報告する前に、Reactがアイランドの要素を切り離します。そのためemitLifecycleは、切り離された要素の代わりにdocument上でイベントをディスパッチします。documentのリスナー(または@tuvix.js/loaderのonLifecycle)は引き続きこのイベントを受け取り、detail.containerは削除された要素を指します。コンテナ自体や祖先要素のリスナーは受け取りません。isMounted(name)は、アプリが他のいずれかのコンテナにマウントされている間はtrueのままで、最後のコンテナがアンマウントされるとfalseになります。 - 開発環境の
<StrictMode>下のアイランド。 Reactはマウントのエフェクト、そのクリーンアップ、再度のエフェクトを順に実行します。アイランドはモジュールの呼び出しをキューに入れ、クリーンアップがすでに実行された最初の実行をスキップするため、モジュールは一度だけマウントされてサーバーのDOMを引き継ぎます。 - マウント失敗後の新しいアプリ。
mountがrejectすると、アイランドはサーバーのHTMLを破棄します。その後に別のappまたはnameを渡すと、新しいモジュールは失敗したアプリのマークアップを引き継がず、空の要素にマウントします。 - アイランドの
ssrHtmlの変更。 アイランドは最初のレンダリングでのみssrHtmlを読みます。新しいコンテンツはpropsで渡します。 - 同じ名前を2回使う場合。
registerModuleは、その名前で最後に作成されたモジュールを保持します。
対象外の機能
- URLからモジュールを読み込みません。モジュールをインポートするか、
@tuvix.js/coreにentry経由で読み込ませます。 - マイクロアプリを文字列にレンダリングしません。サーバーでは
react-dom/serverのrenderToStringをTuvixReactAppと組み合わせて使うか、各フレームワーク自身のレンダラー(@tuvix.js/vue/serverのrenderVueToString、またはSvelteとAngularの同等の関数)を使い、その結果をssrHtmlとしてTuvixIslandに渡します。 MountContext.busやMountContext.nameをAppに渡しません。@tuvix.js/event-busのgetGlobalBus()を使います。- アイランドのCSSを読み込みません。サーバーレンダリングされたアイランドのスタイルシートリンクを出力するには
@tuvix.js/viteを使います。 - React 17以前はサポートしません。
0.4からの移行
TuvixReactAppのサーバーHTMLにSuspenseマーカーが含まれます。 ラッパーはアプリの周囲に<!--$-->…<!--/$-->を含むようになりました。サーバーとクライアントの@tuvix.js/reactを同時にアップグレードしないと、ハイドレーションが不一致で失敗します。
最初のレンダリングで例外が発生するとmountはrejectします。 mountをawaitして解決を前提にしていた場合は、エラーを捕捉します。
import { createReactMicroApp } from '@tuvix.js/react';
function Broken(): never {
throw new Error('boom');
}
const app = createReactMicroApp({ name: 'broken', App: Broken });
const container = document.createElement('div');
try {
await app.mount({ container });
} catch (error) {
// 0.4: never reached. 0.5: the render error; the container is empty.
container.textContent = 'This section failed to load.';
}未知のコンテナに対するunmountは何もしません。 0.4ではコンテナを空にしていました。その動作に依存していた場合は、container.replaceChildren()を自分で呼び出します。
設定オブジェクト。 このページの以前の版ではcreateReactMicroApp(App)と示していました。この関数は設定オブジェクトを受け取ります:createReactMicroApp({ name: 'dashboard', App })。
その他の解説はReactガイドを参照してください。