---
title: "React and Vue on one page"
audience: "anyone mid-migration or with two teams that use different frameworks"
description: "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…"
snippet_caption: "app/src/ui/Holdings.vue"
snippet: "<script setup lang=\"ts\">\nimport { useStore } from \"@snapfire/fsr/client/vue\";\n\nimport { watchedKey } from \"@src/store\";\n\nconst props = defineProps<{ rows: { symbol: string; shares: number }[]; watched: string }>();\nconst held = useStore(watchedKey, props.watched);\n</script>\n\n<template>\n  <table class=\"holdings\">\n    <tbody>\n      <tr v-for=\"row in props.rows\" :key=\"row.symbol\" :class=\"{ on: row.symbol === held.value }\">\n        <td>\n          <button type=\"button\" @click=\"held.value = row.symbol\">{{ row.symbol }}</button>\n        </td>\n        <td>{{ row.shares }}</td>\n      </tr>\n    </tbody>\n  </table>\n</template>"
---

import { Install } from "@src/ui/Install";

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.

<Install captured="0.x" also={["snapfire_vue"]} />

## Add the second framework

Start from a React app and give it a second direction. `fsr use` adds one without touching the other:

```shell-session
$ 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:

```json title="app/importmap.json" {6}
{
  "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](https://snapfirers.com/fsr/learn/200-write-your-islands-in-vue) covers what it does with them.

## Share state between them

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

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

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

React takes it as state:

```tsx title="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`:

```vue title="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:

```ts title="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:

```tsx title="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:

```ts title="app/routes/page.loader.ts" {4-10}
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:

```tsx title="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:

```ts title="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`:

```shell-session
$ 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](https://snapfirers.com/fsr/learn/200-write-your-islands-in-vue) covers that. Nothing in this tutorial depends on which path a component takes.

## Navigate 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:

```shell-session
$ 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:

| | raw | gzip |
| --- | --- | --- |
| React, with its adapter | 248.9K | 77.8K |
| Vue, with its adapter | 136.5K | 52.6K |
| the fsr client | 92.0K | 31.7K |
| this application | 3.7K | 2.0K |
| everything the page loads | 481.1K | 164.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](https://snapfirers.com/fsr/learn/210-write-islands-as-web-components) measures rather than what this table does.

Otherwise use one framework.

Next up: [400. Test a loader](https://snapfirers.com/fsr/learn/400-test-a-loader).
