# Un contador entre React y Vue

Conecta las dos apps del tutorial mediante el store de WU, con archivos completos, permisos, suscripciones y una prueba de desmontaje.

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

## Lo que vas a construir

Continúa en `mi-plataforma`, el mismo workspace de [Añadir una segunda app](/es/docs/guides/add-an-app/). Vas a reemplazar sus contadores locales por un contador compartido: **sumar desde React también cambia Vue, y sumar desde Vue también cambia React**.

La página Astro `/contador/` mostrará las dos apps juntas. Seguirán teniendo sus propios componentes y renderers. El dato común estará en `wu.store`, en el path `demo.counter`.

Necesitas Node.js 24 y el workspace creado con WU CLI 1.2.0, con `mi-plataforma` en React e `inspector` en Vue. El código usa las APIs publicadas de `wu-framework` 4.0.0 y las dependencias que ya instalaste con `wu install`.

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

## 1. Aplicar los archivos al mismo proyecto

Detén el servidor con **Ctrl+C**. [Descarga los archivos completos del contador](/examples/wu-shared-counter.zip) y copia su contenido **dentro de la raíz de `mi-plataforma/`**, junto a su `wu.config.json`.

El ZIP contiene archivos para incorporar al workspace del tutorial; no se instala ni se ejecuta por separado. Reemplaza los archivos con el mismo nombre y conserva las otras carpetas del proyecto. Si ya personalizaste estos archivos, guarda tus cambios antes de sustituirlos.

| Archivo | Responsabilidad |
| --- | --- |
| `mf-mi-plataforma/src/App.tsx` | Mostrar y actualizar el dato desde React. |
| `mf-mi-plataforma/src/main.tsx` | Conectar el montaje y desmontaje de React con WU. |
| `mf-inspector/src/App.vue` | Mostrar y actualizar el mismo dato desde Vue. |
| `mf-inspector/src/main.ts` | Conectar el montaje y desmontaje de Vue con WU. |
| `wu.json` dentro de cada app | Solicitar acceso únicamente a `demo.counter`. |
| `vite.config.js` dentro de cada app | Llevar el manifest fuente al build de esa app. |
| `wu.config.json` | Conservar las dos apps y conceder permisos al usar el shell generado. |
| `shell/src/pages/contador.astro` | Montar ambas apps, conceder permisos e inicializar el dato. |
| `shell/astro.config.mjs` | Emitir el CSS del shell en archivos que `wu serve` puede servir. |

**Si ya agregaste más apps al workspace**, conserva tu `wu.config.json` y añade `"grants": ["store.read:demo.counter", "store.write:demo.counter"]` solo a las entradas existentes `mi-plataforma` e `inspector`. El archivo del ZIP corresponde al tutorial de dos apps.

También puedes copiar manualmente los once archivos completos que aparecen más abajo. No necesitas añadir una biblioteca de componentes ni instalar nuevos paquetes.

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

## 2. Construir y ejecutar

Desde la raíz del workspace:

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

Espera a que `npm --prefix shell run build` termine sin errores. Prepara la nueva página Astro antes de arrancar, porque un shell ya construido puede reutilizarse. Mientras `wu dev --native` está activo, CLI 1.2.0 observa las fuentes del shell, las reconstruye y recarga la página al cambiarlas. Para seguir editando no hace falta detener el servidor; revisa la terminal si Astro informa de un error.

Abre [http://127.0.0.1:3000/contador/](http://127.0.0.1:3000/contador/). Debes ver **Un contador, React y Vue**, las dos tarjetas y un cero en cada una.

1. Pulsa **Sumar desde React**: ambos valores pasan de **0 a 1**.
2. Pulsa **Sumar desde Vue**: ambos pasan de **1 a 2**.
3. Pulsa **Desmontar Vue**: desaparece su tarjeta y React conserva el **2**.
4. Pulsa **Sumar desde React**: React muestra **3**.
5. Pulsa **Montar Vue**: Vue lee el dato actual y también muestra **3**.
6. Pulsa **Restar desde Vue**: ambas apps muestran **2**.

Desmontar la app elimina su interfaz y su suscripción; **no borra el dato compartido**. Recargar toda la página sí inicia una sesión nueva con cero: este ejemplo guarda el dato en memoria, sin persistencia en el navegador ni en un servidor.

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

## 3. Cómo llega un cambio a ambas apps

El shell inicializa `demo.counter` una sola vez. Cada componente lee su valor inicial y se suscribe con `store.on`. Un botón llama a `store.update`; WU escribe el nuevo valor y notifica a los dos componentes. React actualiza su `useState` y Vue actualiza su `ref`.

`update` calcula el nuevo valor a partir del dato actual del store. Así, los botones no dependen de que la última notificación ya se haya pintado en pantalla. Las notificaciones llegan en una microtask; `on` no entrega automáticamente el valor inicial.

Las apps usan **`context.wu`**, la fachada que WU entrega durante su montaje. `waitForWu` localiza el runtime del shell para registrar el lifecycle con `wu.define`; las lecturas y escrituras del componente pasan por la fachada de su propia app.

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

### El componente React

**`mf-mi-plataforma/src/App.tsx`** · [Abrir archivo completo](/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>
  );
}
```

El efecto se suscribe y vuelve a leer el dato para cubrir el tiempo entre el primer render y la conexión. Su `return stop` libera la suscripción cuando React desmonta el componente.

**`mf-mi-plataforma/src/main.tsx`** · [Abrir archivo completo](/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;
  },
});
```

El lifecycle crea el root de React y le pasa `context.wu` como prop. Al desmontar la microapp, `root.unmount()` ejecuta la limpieza del efecto.

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

### El componente Vue

**`mf-inspector/src/App.vue`** · [Abrir archivo completo](/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 conserva un `ref` para pintar el dato. `onUnmounted(stop)` libera la suscripción cuando se retira el componente.

**`mf-inspector/src/main.ts`** · [Abrir archivo completo](/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()` ejecuta la limpieza de Vue. Al montar de nuevo, se crea otra instancia y se lee el valor actual de WU; el componente no reinicia el contador.

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

## 4. Qué permisos se conceden

Cada manifest solicita `store.read` y `store.write` solo para `demo.counter`. La lectura permite consultar y suscribirse; `update` necesita tanto lectura como escritura. El shell concede esos mismos recursos mediante `grants`.

Una app no obtiene acceso al store completo por conocer un path. WU calcula los permisos efectivos a partir de lo solicitado por la app y lo concedido por el host. `required: true` hace que una solicitud sin cobertura impida registrar la app.

**`mf-mi-plataforma/wu.json`** · [Abrir archivo completo](/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`** · [Abrir archivo completo](/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 class="section-anchor" id="section-8"></span>

### Conservar el manifest al compilar

En desarrollo nativo, la CLI sirve el `wu.json` que está en la raíz de cada microapp. Cada configuración Vite añade un pequeño plugin que escribe ese mismo archivo en su `dist/`. Después, `wu build` conserva sus permisos y actualiza `entry` para apuntar al JavaScript compilado. No mantienes una segunda copia del manifest a mano.

**`mf-mi-plataforma/vite.config.js`** · [Abrir archivo completo](/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`** · [Abrir archivo completo](/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 class="section-anchor" id="section-9"></span>

### Mantener el shell generado

Esta configuración mantiene las dos apps del tutorial y añade sus grants. Así, también puedes abrirlas desde la navegación de la página principal del workspace. Esa navegación muestra una app a la vez; `/contador/` permite ver las dos simultáneamente.

**`wu.config.json`** · [Abrir archivo completo](/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 class="section-anchor" id="section-10"></span>

## 5. La página Astro que las conecta

Esta es la página completa. Su script se ejecuta en el navegador, inicializa WU y monta cada app en su contenedor. Declara los grants de esta página explícitamente porque es un host adicional al shell generado.

La inicialización del dato está en el host y se ejecuta antes del montaje. El botón de Vue utiliza `unmount` con `force: true` para esperar la limpieza inmediata antes de permitir otro montaje.

**`shell/src/pages/contador.astro`** · [Abrir archivo completo](/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 class="section-anchor" id="section-11"></span>

### Emitir el CSS del shell en archivos

Esta configuración hace que Astro genere archivos CSS externos, incluidos los estilos de `/contador/`. Así se cargan con la política de estilos de `wu serve`, que permite recursos del mismo origen. Conserva esta opción al personalizar la configuración del shell.

**`shell/astro.config.mjs`** · [Abrir archivo completo](/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 class="section-anchor" id="section-12"></span>

## 6. Comprobar la salida de producción

Detén el servidor con **Ctrl+C** y ejecuta desde la raíz:

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

Abre [http://127.0.0.1:4100/contador/index.html](http://127.0.0.1:4100/contador/index.html) y repite las seis interacciones. Recarga esa misma ruta: deben cargarse otra vez las dos apps y empezar en cero. Estás sirviendo el `dist/` combinado del workspace.

`wu serve 4100 --mode static` resuelve los índices de directorio y devuelve 404 reales para rutas inexistentes. Usa `--mode spa` solo si necesitas fallback al shell para rutas del cliente.

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

### Si algo no coincide

| Lo que observas | Qué revisar |
| --- | --- |
| `/contador/` muestra la página anterior o no existe | Confirma `shell/src/pages/contador.astro` y revisa el build del shell en la terminal. Si copiaste los archivos con desarrollo detenido, ejecuta `npm --prefix shell run build` antes de arrancar. |
| Se deniega un permiso | Cada app debe tener su `wu.json`; los grants del host deben cubrir `store.read` y `store.write` de `demo.counter`. |
| Funciona en desarrollo pero se deniega en producción | Comprueba que copiaste ambos `vite.config.js` y que `dist/mf-mi-plataforma/wu.json` y `dist/mf-inspector/wu.json` conservan `wu.permissions`. |
| Solo cambia uno de los contadores | Reemplaza tanto `App` como `main` en cada app; los cuatro archivos deben corresponder a esta receta. |
| No aparece el valor inicial | La página debe inicializar el store antes de montar; cada componente debe leer al conectarse. |

El siguiente paso es [Pedir una suma a otra app](/es/docs/guides/rpc-between-apps/): una app ofrece una operación y otra pide su resultado. Para profundizar en paths, snapshots y notificaciones, consulta [Estado compartido](/es/docs/framework/state/).