Tutorial 15 of 54 · for anyone mid-migration or with two teams that use different frameworks · Markdown

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 measure what it costs in download size.

Before you start

Install from crates.io. You need no Node and 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
added     vue                          vue/vue.bundle.mjs  128682 bytes
types     @snapfire/fsr/authoring      fsr 0.21.0
types     @snapfire/fsr/client         fsr 0.21.0
types     @snapfire/fsr/desktop        fsr 0.21.0
types     vue                          vue 3.5.44
types     @vue/compiler-dom            @vue/compiler-dom 3.5.44
...
types     source-map-js                source-map-js 1.2.2
wrote     shop/app/generated/plan.sexp
...

One row goes into shop/app/importmap.json, for Vue itself. Everything React is left alone:

app/importmap.json
{
  "imports": {
    "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",
    "vue": "/static/js/vendor/vue/vue.bundle.mjs"
  }
}

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.

Share state between them#

Declare the state once, in plain TypeScript that neither framework knows about:

app/src/store.ts
import { key } from "@snapfire/fsr/client/store";

export const watchedKey = key<string>("shop/watched");

React takes it as state:

app/src/ui/Watch.tsx
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:

app/src/ui/Holdings.vue
<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 or knows what it 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 the islands#

Both islands start from the same symbol. The layout's loader returns it and gives the store key its starting value:

app/routes/layout.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function load(_ctx: Ctx) {
  return { watched: "ACME" };
}

export const defaults = ({ data }: { data: { watched: string } }) => ({ "shop/watched": data.watched });

The key goes under defaults because the visitor owns it. A store seed is authoritative: every navigation seeds it again and puts ACME back over the row the visitor picked. A defaults value only holds the key while nothing in the browser has written it.

The layout holds the React island:

app/routes/layout.tsx
import type { ReactNode } from "react";
import { Island } from "@snapfire/fsr/client/react";
import { Link } from "@snapfire/fsr/components";
import Watch from "@src/ui/Watch";

export default function Layout({ watched, children }: { watched: string; children: ReactNode }) {
  return (
    <div className="shell">
      <header className="masthead">
        <Link href="/">Board</Link>
        <Link href="/news">News</Link>
        <Island when="load">
          <Watch symbol={watched} />
        </Island>
      </header>
      <main>{children}</main>
    </div>
  );
}

The page's loader returns the rows and the same starting symbol for the first paint:

app/routes/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function load(_ctx: Ctx<"/">) {
  return {
    rows: [
      { symbol: "ACME", shares: 120 },
      { symbol: "GLOBX", shares: 40 },
    ],
    watched: "ACME",
  };
}

The page holds the Vue island, placed from a React template:

app/routes/page.tsx
import type { RootProps } from "@generated/client";
import { Island } from "@snapfire/fsr/client/react";
import Holdings from "@src/ui/Holdings.vue";

export default function Board({ rows, watched }: RootProps) {
  return (
    <section>
      <h1>Holdings</h1>
      <Island when="load">
        <Holdings rows={rows} watched={watched} />
      </Island>
    </section>
  );
}

Build it and both adapters turn up in the registry:

app/generated/islands.ts
import { registerDeferredIsland } from "@snapfire/fsr/client";

const react = () => import("@snapfire/fsr/client/react").then((m) => ({ mount: m.reactMounter, patch: m.reactPatcher, unmount: m.reactUnmounter, pause: m.reactPauser }));

const vue = () => import("@snapfire/fsr/client/vue").then((m) => ({ mount: m.vueMounter, patch: m.vuePatcher, unmount: m.vueUnmounter }));

export function registerIslands(): void {
  registerDeferredIsland("src/ui/Watch.tsx#default", { loader: () => import("../src/ui/Watch.js").then((m) => m.default), adapter: react });
  registerDeferredIsland("src/ui/Holdings.vue#default", { loader: () => import("../src/ui/Holdings.vue").then((m) => m.default), adapter: vue });
}

A placement carries a module id and the registry says which adapter that id uses. The plan, the payload and the renderer never look past the id, so the registry is the only place the two frameworks differ.

Compare 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" data-sf-ssr><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" data-sf-ssr><table class="holdings"><tbody><!--[--><tr class="on"><td><button type="button">ACME</button></td><td>120</td></tr><tr class=""><td><button type="button">GLOBX</button></td><td>40</td></tr><!--]--></tbody></table></sf-i>
<script type="application/json" data-sf-props="sf-i1">{"rows":[{"symbol":"ACME","shares":{"$":"f","v":120.0}},{"symbol":"GLOBX","shares":{"$":"f","v":40.0}}],"watched":"ACME","$k":"routes/page.tsx#default|i0"}</script>

Both markers carry the markup the server rendered and both frameworks hydrate over it. Each was written the way that framework's own server renderer writes it. React's <!-- --> separates the text from the value. Vue's <!--[--> and <!--]--> bracket the v-for list.

A .vue component holding something the build can't read, a <Teleport> to a class selector say, arrives as an empty marker and Vue mounts it fresh; the report says which path a component took. 200 covers that. Nothing in this tutorial depends on which path a component takes.

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":"247b85c010b89e99","p":[],"c":[{"k":"routes/layout.tsx#default","d":"a325db579b82e6a8","n":"content","p":[3],"c":[{"k":"routes/page.tsx#default","d":"a25fd0891d61b3e8","n":"content","p":[5],"c":[]}]}]}

$ curl -s 'localhost:3000/news?__payload' | grep '^G'
G {"k":"shell#document","d":"247b85c010b89e99","p":[],"c":[{"k":"routes/layout.tsx#default","d":"0039d3f270496a48","n":"content","p":[3],"c":[{"k":"routes/news/page.tsx#default","d":"9c71c283b77683bc","n":"content","p":[5],"c":[]}]}]}

d is a digest of each segment's markup. The shell's is the same on both pages. The layout's moved, because Link marks the current page with aria-current="page" and that moved from Board to News. So when you click News the navigator updates the layout's markup and keeps every island the new markup still places, by its region key. The React masthead keeps its DOM and its state. The page segment underneath is replaced. 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 kept its value.

Measure the cost#

Measured from the files the board page loads under RELEASE_ENV=production, gzip at level 9:

rawgzip
React, with its adapter248.9K77.8K
Vue, with its adapter136.5K52.6K
the fsr client92.0K31.7K
this application3.7K2.0K
everything the page loads481.1K164.2K

React and Vue together are about 130K compressed before any of your own code. A page that uses both loads both on every visit.

Mixing frameworks is only worth that cost in these cases:

  • 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 and another owns a panel in it, so neither has to switch frameworks first.
  • Zero-runtime pieces. A custom element needs no framework, so dropping one into a React page costs what 210 measures rather than what this table does.

Otherwise use one framework.

Next up: 400. Test a loader.