React and Vue on one page
You'll put a React island and a Vue island in the same document, have them share one piece of state, then navigate between a page whose island is Vue and a page whose island is React while the layout stays exactly where it is. At the end you'll weigh what it cost, because it isn't free.
Before you start
Straight from crates.io. No Node, no package manager.
cargo install snapfire_compiler
cargo install snapfire_fsr_cli
cargo install snapfire_vue
fsr --version
Every command and screenshot on this page was captured with fsr 0.x.
Add the second framework
Start from a React app and give it a second direction. fsr use adds one without touching the other:
$ fsr new shop --with react
$ fsr use shop/app vue
mapped @snapfire/fsr-client/vue /static/js/fsr/vue.js
added vue vue/vue.bundle.mjs 117786 bytes
types @snapfire/fsr-authoring fsr 0.13.0
types @snapfire/fsr-client fsr 0.13.0
types vue vue 3.5.43
Two rows go into shop/app/importmap.json, the client's Vue adapter and Vue itself. Everything React is left alone:
{
"imports": {
"@snapfire/fsr-client": "/static/js/fsr/index.js",
"@snapfire/fsr-client/std": "/static/js/fsr/std.js",
"@snapfire/fsr-client/store": "/static/js/fsr/store.js",
"@snapfire/fsr-client/react": "/static/js/fsr/react.js",
"@snapfire/fsr-authoring/template": "/static/js/fsr/template.js",
"react": "/static/js/vendor/react/react.bundle.mjs",
"react/jsx-runtime": "/static/js/vendor/react/jsx-runtime.bundle.mjs",
"react-dom/client": "/static/js/vendor/react-dom/client.bundle.mjs",
"@snapfire/fsr-client/vue": "/static/js/fsr/vue.js",
"vue": "/static/js/vendor/vue/vue.bundle.mjs"
}
}Directions stack, which is the whole reason this tutorial is short. Nothing about adding Vue asks React to move over.
snapfirec hands .vue files off to snapfirec-vue, which is the third cargo install in the box above. 200 covers what it does with them.
One key, two adapters
Declare the state once, in plain TypeScript that neither framework knows about:
import { key } from "@snapfire/fsr-client/store";
export const watchedKey = key<string>("shop/watched");React takes it as state:
import { useStore } from "@snapfire/fsr-client/react";
import { watchedKey } from "@src/store";
export default function Watch({ symbol }: { symbol: string }) {
const [held] = useStore(watchedKey, symbol);
return <span className="watch">watching {held}</span>;
}Vue takes it as a reactive holder. Remember it isn't a ref, so you keep the .value:
<script setup lang="ts">
import { useStore } from "@snapfire/fsr-client/vue";
import { watchedKey } from "@src/store";
const props = defineProps<{ rows: { symbol: string; shares: number }[]; watched: string }>();
const held = useStore(watchedKey, props.watched);
</script>
<template>
<table class="holdings">
<tbody>
<tr v-for="row in props.rows" :key="row.symbol" :class="{ on: row.symbol === held.value }">
<td>
<button type="button" @click="held.value = row.symbol">{{ row.symbol }}</button>
</td>
<td>{{ row.shares }}</td>
</tr>
</tbody>
</table>
</template>Click a row in the Vue table and the React masthead re-renders with the new symbol. Neither component imports the other. Neither knows what the other is written in. Both are rendered on the server with the same value as a prop, so the first paint already agrees with the store before any script runs.
Place them normally
The layout holds the React island:
import { Island, Link } from "@snapfire/fsr-client/react";
import Watch from "@src/ui/Watch";
<Island when="load">
<Watch symbol={watched} />
</Island>;The page holds the Vue one, placed from a React template, which is fine:
import { Island } from "@snapfire/fsr-client/react";
import Holdings from "@src/ui/Holdings.vue";
<Island when="load">
<Holdings rows={rows} watched={watched} />
</Island>;Build it and both mounters turn up in the registry:
import { registerIsland } from "@snapfire/fsr-client";
import { reactMounter, reactPatcher, reactUnmounter } from "@snapfire/fsr-client/react";
import { vueMounter, vuePatcher, vueUnmounter } from "@snapfire/fsr-client/vue";
export function registerIslands(): void {
registerIsland("src/ui/Watch.tsx#default", { loader: () => import("../src/ui/Watch.js").then((m) => m.default), mount: reactMounter, patch: reactPatcher, unmount: reactUnmounter });
registerIsland("src/ui/Holdings.vue#default", { loader: () => import("../src/ui/Holdings.vue").then((m) => m.default), mount: vueMounter, patch: vuePatcher, unmount: vueUnmounter });
}Nothing else was needed. A placement carries a module id; the registry says which mounter that id uses; the plan, the payload and the renderer never look any deeper. Mixing frameworks falls out of the registry being a plain lookup table, so nobody had to build support for it.
What the two look like in the markup
The difference shows up in curl:
$ curl -s localhost:3000/
<sf-i id="sf-i0" data-sf-module="src/ui/Watch.tsx#default">
<span class="watch">watching ACME</span>
</sf-i>
<script type="application/json" data-sf-props="sf-i0">{"symbol":"ACME","$k":"routes/layout.tsx#default|i0"}</script>
<sf-i id="sf-i1" data-sf-module="src/ui/Holdings.vue#default">
<table class="holdings" data-v-…>…</table>
</sf-i>
<script type="application/json" data-sf-props="sf-i1">{"rows":[{"symbol":"ACME","shares":{"$":"f","v":120.0}},…],"watched":"ACME","$k":"routes/page.tsx#default|i0"}</script>
<script type="application/json" data-sf-store>{"shop/watched":"ACME"}</script>
Both markers carry the markup the server rendered and both frameworks hydrate over it. The React one is React's spelling, the Vue one is Vue's, data-v- stamps and all, because each was written the way that framework's own server renderer writes it. Both read the same store seed at the bottom.
That symmetry is recent. A .vue file used to arrive as an empty marker that Vue mounted fresh; now the build reads it through Vue's parser and lowers it like any other component. One holding something the build can't read, a v-model say, still arrives empty and still mounts fresh; the report says which of the two a component took. 200 covers that. Nothing in this tutorial depends on which path a component takes.
Navigating between them
Add a /news page whose island is React, under the same layout. Now ask for both payloads and look at the segment tree:
$ curl -s 'localhost:3000/?__payload' | grep '^G'
G {"k":"shell#document","d":"39d021d438116344","c":[{"k":"routes/layout.tsx#default","d":"8d00e9accc3e72c1","c":[
{"k":"routes/page.tsx#default","d":"91bb2e38b879e685"}]}]}
$ curl -s 'localhost:3000/news?__payload' | grep '^G'
G {"k":"shell#document","d":"39d021d438116344","c":[{"k":"routes/layout.tsx#default","d":"8d00e9accc3e72c1","c":[
{"k":"routes/news/page.tsx#default","d":"6f8f893d0e6e344e"}]}]}
d is a digest of each segment's markup. The layout's is byte-identical on both pages, so when you click News the navigator keeps that segment's DOM exactly as it stands (React masthead island included) and swaps only the page segment underneath it. It goes from a segment holding a Vue island to one holding a React island without consulting either framework. It just applies segments by key and hands each region's props to whatever is mounted there.
Click back to the board and your selected row is still selected, because the store never went anywhere.
What it costs
Here's the part worth reading before you reach for any of this. Measured from the files this page actually loads, gzip at level 9:
| raw | gzip | |
|---|---|---|
| React, with its adapter | 152.4K | 49.1K |
| Vue, with its adapter | 126.4K | 49.3K |
| the fsr client | 61.2K | 17.9K |
| this application | 3.7K | 1.9K |
| everything the page loads | 343.6K | 118.1K |
React and Vue together are about 98K compressed before a single line of your own code runs. A page that needs both pays for both on every visit. No amount of clever seam design makes that cheaper.
It earns its keep in a few specific situations:
- A migration. You move off one framework island by island instead of in one jump, with both running until the last one is gone.
- Two teams, one page. One group owns the shell; another owns a panel in it; neither has to win the framework argument first.
- Zero-runtime pieces. A custom element is just the browser, so dropping one into a React page costs what 210 measures rather than what this table does.
If one framework will do the job, use one.
Recap
Two frameworks in one document needed one extra import-map row and one extra cargo install. The state they share is a key in a .ts file that neither of them knows about. The router moved between their segments without ever asking which was which.
Next up: 100. Test a loader without a browser.