# A counter between React and Vue

Connect both tutorial apps through the WU store, with complete files, permissions, subscriptions, and an unmount test.

<span id="lo-que-vas-a-construir"></span>

<span class="section-anchor" id="section-1"></span>

## What you will build

Continue in `mi-plataforma`, the same workspace from [Adding a second app](/docs/guides/add-an-app/). Replace its local counters with a shared counter: **incrementing from React also changes Vue, and incrementing from Vue also changes React**.

The Astro `/contador/` page displays both apps together. They retain their own components and renderers. Shared data lives in `wu.store` at `demo.counter`.

You need Node.js 24 and the workspace created with WU CLI 1.2.0, with React `mi-plataforma` and Vue `inspector`. The code uses published `wu-framework` 4.0.0 APIs and dependencies already installed with `wu install`.

<span id="1-aplicar-los-archivos-al-mismo-proyecto"></span>

<span class="section-anchor" id="section-2"></span>

## 1. Apply files to the same project

Stop the server with **Ctrl+C**. [Download the complete counter files](/examples/wu-shared-counter.zip) and copy the contents **inside the `mi-plataforma/` root**, beside its `wu.config.json`.

The ZIP contains files to integrate into the tutorial workspace; it is not installed or run separately. Replace same-named files and preserve other project folders. If you already customized these files, save your changes before replacing them.

| File | Responsibility |
| --- | --- |
| `mf-mi-plataforma/src/App.tsx` | Display and update data from React. |
| `mf-mi-plataforma/src/main.tsx` | Connect React mounting and unmounting to WU. |
| `mf-inspector/src/App.vue` | Display and update the same data from Vue. |
| `mf-inspector/src/main.ts` | Connect Vue mounting and unmounting to WU. |
| `wu.json` inside each app | Request access only to `demo.counter`. |
| `vite.config.js` inside each app | Include the source manifest in that app's build. |
| `wu.config.json` | Preserve both apps and grant permissions when using the generated shell. |
| `shell/src/pages/contador.astro` | Mount both apps, grant permissions, and initialize data. |
| `shell/astro.config.mjs` | Emit shell CSS as files that `wu serve` can serve. |

**If you already added more workspace apps**, preserve your `wu.config.json` and add `"grants": ["store.read:demo.counter", "store.write:demo.counter"]` only to existing `mi-plataforma` and `inspector` entries. The ZIP file matches the two-app tutorial.

You can also manually copy the eleven complete files below. You do not need a component library or new packages.

<span id="2-construir-y-ejecutar"></span>

<span class="section-anchor" id="section-3"></span>

## 2. Build and run

From the workspace root:

```sh
npm --prefix shell run build
wu dev --native --port 3000 --no-open
```

Wait for `npm --prefix shell run build` to finish without errors. It prepares the new Astro page before startup, because an existing shell build may be reused. While `wu dev --native` is running, CLI 1.2.0 watches shell sources, rebuilds them and reloads the page when they change. You can keep editing without stopping the server; inspect the terminal if Astro reports an error.

Open [http://127.0.0.1:3000/contador/](http://127.0.0.1:3000/contador/). You should see **Un contador, React y Vue**, the two cards and a zero in each.

1. Click **Sumar desde React**: both values change from **0 to 1**.
2. Click **Sumar desde Vue**: both change from **1 to 2**.
3. Click **Desmontar Vue**: its card disappears and React retains **2**.
4. Click **Sumar desde React**: React displays **3**.
5. Click **Montar Vue**: Vue reads the current data and also displays **3**.
6. Click **Restar desde Vue**: both apps display **2**.

Unmounting removes the app's interface and subscription; **it does not delete shared data**. Reloading the whole page starts a new session at zero: this example stores data in memory, without browser or server persistence.

<span id="3-cómo-llega-un-cambio-a-ambas-apps"></span>

<span class="section-anchor" id="section-4"></span>

## 3. How a change reaches both apps

The shell initializes `demo.counter` once. Each component reads its initial value and subscribes with `store.on`. A button calls `store.update`; WU writes the new value and notifies both components. React updates its `useState`, and Vue updates its `ref`.

`update` calculates the new value from current store data. Buttons therefore do not depend on the latest notification already being painted. Notifications arrive in a microtask; `on` does not automatically deliver the initial value.

Apps use **`context.wu`**, the facade WU supplies during mounting. `waitForWu` locates the shell runtime to register lifecycle with `wu.define`; component reads and writes go through their own app's facade.

<span id="el-componente-react"></span>

<span class="section-anchor" id="section-5"></span>

### The React component

**`mf-mi-plataforma/src/App.tsx`** · [Open complete file](/examples/browser/shared-counter/mf-mi-plataforma/src/App.tsx.txt)

```tsx
import { useEffect, useState } from 'react';
import type { WuAppFacade } from 'wu-framework';

export default function App({ appWu }: { appWu: WuAppFacade }) {
  const [count, setCount] = useState(() => appWu.store.get('demo.counter') ?? 0);

  useEffect(() => {
    const stop = appWu.store.on('demo.counter', (value) => setCount(value));
    setCount(appWu.store.get('demo.counter') ?? 0);
    return stop;
  }, [appWu]);

  function change(delta: number) {
    appWu.store.update('demo.counter', (current) => (current ?? 0) + delta);
  }

  return (
    <section aria-label="Contador React">
      <h2>React · mi-plataforma</h2>
      <p>Valor compartido: <output aria-live="polite">{count}</output></p>
      <button type="button" onClick={() => change(-1)}>Restar desde React</button>{' '}
      <button type="button" onClick={() => change(1)}>Sumar desde React</button>
    </section>
  );
}
```

The effect subscribes and rereads data to cover the time between first render and connection. Its `return stop` releases the subscription when React unmounts the component.

**`mf-mi-plataforma/src/main.tsx`** · [Open complete file](/examples/browser/shared-counter/mf-mi-plataforma/src/main.tsx.txt)

```tsx
import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { waitForWu } from 'wu-framework/adapters/shared';
import App from './App.tsx';

const wu = await waitForWu();
let root: Root | undefined;

wu.define('mi-plataforma', {
  mount(container, context) {
    if (!context?.wu) throw new Error('Esta app necesita el contexto de WU.');
    root = createRoot(container);
    root.render(<App appWu={context.wu} />);
  },
  unmount() {
    root?.unmount();
    root = undefined;
  },
});
```

Lifecycle creates the React root and passes `context.wu` as a prop. When the microapp unmounts, `root.unmount()` executes effect cleanup.

<span id="el-componente-vue"></span>

<span class="section-anchor" id="section-6"></span>

### The Vue component

**`mf-inspector/src/App.vue`** · [Open complete file](/examples/browser/shared-counter/mf-inspector/src/App.vue.txt)

```vue
<script setup lang="ts">
import { onUnmounted, ref } from 'vue';
import type { WuAppFacade } from 'wu-framework';

const { appWu } = defineProps<{ appWu: WuAppFacade }>();
const count = ref(appWu.store.get('demo.counter') ?? 0);
const stop = appWu.store.on('demo.counter', (value) => { count.value = value; });
onUnmounted(stop);

function change(delta: number) {
  appWu.store.update('demo.counter', (current) => (current ?? 0) + delta);
}
</script>

<template>
  <section aria-label="Contador Vue">
    <h2>Vue · inspector</h2>
    <p>Valor compartido: <output aria-live="polite">{{ count }}</output></p>
    <button type="button" @click="change(-1)">Restar desde Vue</button>
    {{ ' ' }}
    <button type="button" @click="change(1)">Sumar desde Vue</button>
  </section>
</template>
```

Vue keeps a `ref` to display data. `onUnmounted(stop)` releases the subscription when the component is removed.

**`mf-inspector/src/main.ts`** · [Open complete file](/examples/browser/shared-counter/mf-inspector/src/main.ts.txt)

```ts
import { createApp, type App as VueApp } from 'vue';
import { waitForWu } from 'wu-framework/adapters/shared';
import App from './App.vue';

const wu = await waitForWu();
let app: VueApp | undefined;

wu.define('inspector', {
  mount(container, context) {
    if (!context?.wu) throw new Error('Esta app necesita el contexto de WU.');
    app = createApp(App, { appWu: context.wu });
    app.mount(container);
  },
  unmount() {
    app?.unmount();
    app = undefined;
  },
});
```

`app.unmount()` executes Vue cleanup. Mounting again creates another instance and reads the current WU value; the component does not reset the counter.

<span id="4-qué-permisos-se-conceden"></span>

<span class="section-anchor" id="section-7"></span>

## 4. Which permissions are granted

Each manifest requests `store.read` and `store.write` only for `demo.counter`. Reading permits querying and subscribing; `update` requires both reading and writing. The shell grants those same resources through `grants`.

Knowing a path does not give an app access to the entire store. WU computes effective permissions from app requests and host grants. `required: true` prevents app registration if a request lacks coverage.

**`mf-mi-plataforma/wu.json`** · [Open complete file](/examples/browser/shared-counter/mf-mi-plataforma/wu.json.txt)

```json
{
  "name": "mi-plataforma",
  "version": "1.0.0",
  "entry": "src/main.tsx",
  "styleMode": "shared",
  "wu": {
    "permissions": [
      { "capability": "store.read", "resource": "demo.counter", "required": true },
      { "capability": "store.write", "resource": "demo.counter", "required": true }
    ]
  }
}
```

**`mf-inspector/wu.json`** · [Open complete file](/examples/browser/shared-counter/mf-inspector/wu.json.txt)

```json
{
  "name": "inspector",
  "version": "1.0.0",
  "entry": "src/main.ts",
  "styleMode": "shared",
  "wu": {
    "permissions": [
      { "capability": "store.read", "resource": "demo.counter", "required": true },
      { "capability": "store.write", "resource": "demo.counter", "required": true }
    ]
  }
}
```

<span id="conservar-el-manifest-al-compilar"></span>

<span class="section-anchor" id="section-8"></span>

### Preserving the manifest during compilation

In native development, the CLI serves `wu.json` from each microapp root. Each Vite configuration adds a small plugin writing that same file to its `dist/`. Then `wu build` preserves permissions and updates `entry` to point to compiled JavaScript. You do not manually maintain another manifest copy.

**`mf-mi-plataforma/vite.config.js`** · [Open complete file](/examples/browser/shared-counter/mf-mi-plataforma/vite.config.js.txt)

```js
import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react(),
    {
      name: 'emit-wu-manifest',
      generateBundle() {
        this.emitFile({
          type: 'asset',
          fileName: 'wu.json',
          source: readFileSync(new URL('./wu.json', import.meta.url), 'utf8'),
        });
      },
    },
  ],
  build: { target: 'esnext' },
});
```

**`mf-inspector/vite.config.js`** · [Open complete file](/examples/browser/shared-counter/mf-inspector/vite.config.js.txt)

```js
import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [
    vue(),
    {
      name: 'emit-wu-manifest',
      generateBundle() {
        this.emitFile({
          type: 'asset',
          fileName: 'wu.json',
          source: readFileSync(new URL('./wu.json', import.meta.url), 'utf8'),
        });
      },
    },
  ],
  build: { target: 'esnext' },
});
```

<span id="mantener-el-shell-generado"></span>

<span class="section-anchor" id="section-9"></span>

### Keeping the generated shell

This configuration preserves both tutorial apps and adds grants. You can also open them from the workspace main-page navigation. That navigation displays one app at a time; `/contador/` displays both simultaneously.

**`wu.config.json`** · [Open complete file](/examples/browser/shared-counter/wu.config.json.txt)

```json
{
  "name": "mi-plataforma",
  "version": "1.1.0",
  "shell": {
    "dir": "shell", "port": 4321, "framework": "astro",
    "dev_cmd": "npx astro dev", "build_cmd": "npx astro build"
  },
  "apps": [
    {
      "name": "mi-plataforma", "dir": "mf-mi-plataforma", "framework": "react", "port": 5001,
      "grants": ["store.read:demo.counter", "store.write:demo.counter"]
    },
    {
      "name": "inspector", "dir": "mf-inspector", "framework": "vue", "port": 5002,
      "grants": ["store.read:demo.counter", "store.write:demo.counter"]
    }
  ],
  "proxy": { "port": 3000, "open_browser": true }
}
```

<span id="5-la-página-astro-que-las-conecta"></span>

<span class="section-anchor" id="section-10"></span>

## 5. The Astro page connecting them

This is the complete page. Its script runs in the browser, initializes WU, and mounts each app in its container. It declares page grants explicitly because it is an additional host alongside the generated shell.

Data initialization lives in the host and runs before mounting. The Vue button uses `unmount` with `force: true` to await immediate cleanup before allowing another mount.

**`shell/src/pages/contador.astro`** · [Open complete file](/examples/browser/shared-counter/shell/src/pages/contador.astro.txt)

```astro
<!doctype html>
<html lang="es">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Un contador compartido · WU</title>
    <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
  </head>
  <body>
    <main>
      <a href="/">Volver al workspace</a>
      <h1>Un contador, React y Vue</h1>
      <p>Las dos apps leen y escriben el mismo dato en WU.</p>
      <p id="estado" role="status">Cargando las apps…</p>
      <div class="contadores">
        <div id="react-counter"></div>
        <div id="vue-counter"></div>
      </div>
      <button id="toggle-vue" type="button" disabled>Desmontar Vue</button>
      <p>Desmonta Vue, cambia el valor desde React y vuelve a montar Vue.</p>
    </main>
    <script>
      import { wu } from 'wu-framework';

      const status = document.querySelector<HTMLElement>('#estado')!;
      const toggle = document.querySelector<HTMLButtonElement>('#toggle-vue')!;
      let vueMounted = false;

      try {
        await wu.init({
          sandbox: 'module',
          apps: [
            {
              name: 'mi-plataforma', url: '/mf-mi-plataforma',
              grants: ['store.read:demo.counter', 'store.write:demo.counter'],
            },
            {
              name: 'inspector', url: '/mf-inspector',
              grants: ['store.read:demo.counter', 'store.write:demo.counter'],
            },
          ],
        });
        if (wu.store.get('demo.counter') === undefined) wu.store.set('demo.counter', 0);
        await wu.mount('mi-plataforma', '#react-counter');
        await wu.mount('inspector', '#vue-counter');
        vueMounted = true;
        toggle.disabled = false;
        status.textContent = 'React y Vue conectados.';
      } catch (error) {
        status.textContent = `No se pudo iniciar: ${error}`;
      }

      toggle.addEventListener('click', async () => {
        toggle.disabled = true;
        try {
          if (vueMounted) {
            await wu.unmount('inspector', { force: true });
          } else {
            await wu.mount('inspector', '#vue-counter');
          }
          vueMounted = !vueMounted;
          toggle.textContent = vueMounted ? 'Desmontar Vue' : 'Montar Vue';
          status.textContent = vueMounted ? 'React y Vue conectados.' : 'Vue desmontado. React sigue conectado.';
        } catch (error) {
          status.textContent = `No se pudo cambiar Vue: ${error}`;
        } finally {
          toggle.disabled = false;
        }
      });
    </script>
    <style is:global>
      body { margin: 0; font: 1rem/1.6 system-ui, sans-serif; color: #192a3b; background: #f5f7fb; }
      main { max-width: 64rem; margin: auto; padding: 2rem 1rem; }
      a { color: #124ca6; }
      .contadores { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: 1rem; margin: 1.5rem 0; }
      section { padding: 1.5rem; border: 1px solid #bac7d6; border-radius: 1rem; background: white; }
      h2 { margin-top: 0; font-size: 1.25rem; }
      output { font-size: 2rem; font-weight: 700; }
      button { font: inherit; padding: .6rem .9rem; margin-block: .25rem; border: 1px solid #617387; border-radius: .4rem; background: white; color: #192a3b; cursor: pointer; }
      button:focus-visible, a:focus-visible { outline: 3px solid #155fc9; outline-offset: 3px; }
      button:disabled { cursor: wait; opacity: .6; }
    </style>
  </body>
</html>
```

<span id="emitir-el-css-del-shell-en-archivos"></span>

<span class="section-anchor" id="section-11"></span>

### Emitting shell CSS as files

This configuration makes Astro generate external CSS files, including `/contador/` styles. They therefore load under `wu serve`'s style policy, which allows same-origin resources. Preserve this option when customizing shell configuration.

**`shell/astro.config.mjs`** · [Open complete file](/examples/browser/shared-counter/shell/astro.config.mjs.txt)

```js
import { defineConfig } from 'astro/config';

export default defineConfig({
  server: { port: 4321 },
  build: { inlineStylesheets: 'never' },
});
```

<span id="6-comprobar-la-salida-de-producción"></span>

<span class="section-anchor" id="section-12"></span>

## 6. Check production output

Stop the server with **Ctrl+C** and run from the root:

```sh
wu build
wu serve 4100 --mode static
```

Open [http://127.0.0.1:4100/contador/index.html](http://127.0.0.1:4100/contador/index.html) and repeat all six interactions. Reload that same route: both apps should load again at zero. You are serving the workspace's combined `dist/`.

`wu serve 4100 --mode static` resolves directory indexes and returns real 404 responses for missing routes. Use `--mode spa` only when client routes need shell fallback.

<span id="si-algo-no-coincide"></span>

<span class="section-anchor" id="section-13"></span>

### If something does not match

| What you observe | What to review |
| --- | --- |
| `/contador/` displays the previous page or is missing | Check `shell/src/pages/contador.astro` and inspect the shell build in the terminal. If you copied the files while development was stopped, run `npm --prefix shell run build` before starting. |
| A permission is denied | Each app needs its `wu.json`; host grants must cover `store.read` and `store.write` on `demo.counter`. |
| Works in development but is denied in production | Check both `vite.config.js` files were copied and `dist/mf-mi-plataforma/wu.json` and `dist/mf-inspector/wu.json` preserve `wu.permissions`. |
| Only one counter changes | Replace both `App` and `main` in each app; all four files must match this recipe. |
| The initial value does not appear | The page must initialize the store before mounting; each component must read on connection. |

Next, [Request a sum from another app](/docs/guides/rpc-between-apps/): one app offers an operation and another requests its result. For paths, snapshots, and notifications in more depth, see [Shared state](/docs/framework/state/).