A counter between React and Vue
Connect both tutorial apps through the WU store, with complete files, permissions, subscriptions, and an unmount test.
What you will build
Continue in mi-plataforma, the same workspace from Adding a second 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.
1. Apply files to the same project
Stop the server with Ctrl+C. Download the complete counter files 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.
2. Build and run
From the workspace root:
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/. You should see Un contador, React y Vue, the two cards and a zero in each.
- Click Sumar desde React: both values change from 0 to 1.
- Click Sumar desde Vue: both change from 1 to 2.
- Click Desmontar Vue: its card disappears and React retains 2.
- Click Sumar desde React: React displays 3.
- Click Montar Vue: Vue reads the current data and also displays 3.
- 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.
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.
The React component
mf-mi-plataforma/src/App.tsx · Open complete file
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
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.
The Vue component
mf-inspector/src/App.vue · Open complete file
<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
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.
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
{
"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
{
"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 }
]
}
}
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
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
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' },
});
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
{
"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 }
}
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
<!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>
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
import { defineConfig } from 'astro/config';
export default defineConfig({
server: { port: 4321 },
build: { inlineStylesheets: 'never' },
});
6. Check production output
Stop the server with Ctrl+C and run from the root:
wu build
wu serve 4100 --mode static
Open 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.
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: one app offers an operation and another requests its result. For paths, snapshots, and notifications in more depth, see Shared state.