Skip to content

Decoupling a Slice from an Existing App

The React and Vue quick starts both assume you’re starting from nothing — a host that resolves all of its shared dependencies through the import map, same as its slices. This guide is for the opposite situation: you already have a working, normally-bundled application, and you want to peel off one piece of it into an independently built and deployed slice, today, without first re-architecting how the rest of the app resolves React or Vue.

That piece doesn’t have to be heading toward a larger migration. A panel that changes on a different schedule than the rest of the app, that a different team owns, or that you’d simply rather stop coordinating releases around, is reason enough on its own — the toolkit doesn’t need to become your whole architecture to be worth using for one thing.

A slice built for the plain-component or mount/unmount conventions is expected to bring its own React or Vue, typically resolved through the import map (@esm.sh/react). That’s fine for a slice with no hooks or reactive state. It breaks the moment the slice calls React.useEffect or uses a Vue ref — those look up state that lives inside the specific React or Vue module instance driving the render, and an already-bundled host’s own copy is a different module instance than whatever the slice fetched separately.

This isn’t only about code the slice writes itself. A bundled third-party dependency that touches React at module-evaluation time — a UI library that calls React.createContext as soon as it’s imported, before any component has rendered — hits the identical problem, and it’s easy to miss since it’s not code you wrote. A slice built on this convention needs to stick to dependencies that only touch React inside render functions, not at import time; anything that needs page-wide singleton state beyond React itself isn’t something this convention covers yet.

createInjectedModuleBoundary solves this the direct way: the slice doesn’t import React or Vue at all. It default-exports a factory that receives the host’s own instance as an argument. (That’s the slice author’s side of the contract — if you’re the one building the slice rather than integrating it into a host, see the API Reference for its exact shape.)

Here’s what this looks like on a real host — a marketing page in a React SPA where one panel (a pricing plans feature) is handed off to another team to own and ship independently. Everything the application team touches to make that handoff happen — nothing else in the host’s build, pipeline, or architecture changes.

1. One dependency (package.json) — @rmc-toolkit/core is its peer:

"devDependencies": {
"@rmc-toolkit/core": "0.4.0",
"@rmc-toolkit/react": "0.4.0"
}

2. A hardcoded import map (index.html) — must come before any module script:

<head>
<script type="importmap">
{ "imports": { "@acme/pricing-plans": "https://slices.example.com/pricing-plans/index.mjs" } }
</script>
<!-- …rest of head… -->
</head>

This is the only place the host names where the slice lives, and the only thing that changes when the slice moves or ships a new build. The key (@acme/pricing-plans) is the specifier — the same string the boundary wrapper below passes as specifier, and the same one any other slice would use in an import statement. The value is the URL the browser actually fetches: where the slice’s built module is hosted, resolved at runtime rather than at the host’s build time.

3. A small boundary wrapper — the whole integration. It creates the boundary once with the host’s React, and maps the host’s own locale into context:

// pricing-plans-slice.tsx (host-owned; the only new host file)
import React from 'react'
import { createInjectedModuleBoundary } from '@rmc-toolkit/react'
import { useLocale } from '@web-main/locale'
// The host's own React instance, so the slice shares it instead of loading a
// second copy.
const { InjectedModuleBoundary } = createInjectedModuleBoundary(React)
export const PricingPlansSlice = () => {
const { locale } = useLocale()
return (
<InjectedModuleBoundary
specifier="@acme/pricing-plans" // resolved by the import map at runtime
context={{ data: { locale } }}
fallback={null}
errorFallback={null} // a slice failure renders nothing
/>
)
}

4. Render it where the feature used to be:

// marketing-page.tsx — the ONLY change to existing host code
-import { PricingPlansContainer } from './pricing-plans-container'
+import { PricingPlansSlice } from './pricing-plans-slice'
case PanelType.Plans:
return (
<Panel {...panel}>
- <PricingPlansContainer />
+ <PricingPlansSlice />
</Panel>
)

That’s the whole host-side change: +1 dependency, +1 import-map tag, +1 wrapper file, and a one-line render swap. The application team never touches the pricing domain’s code again — it lives in the other team’s repository and ships on their own pipeline.

Once the slice is wired in, the next question is usually “how do I iterate on the slice without running the whole host app?” A small standalone harness answers that — and it reuses the exact same integration code from step 3, not a simplified stand-in for it:

// dev/App.tsx — identical call shape to the host's own wrapper
import React from "react";
import { createInjectedModuleBoundary } from "@rmc-toolkit/react";
const { InjectedModuleBoundary } = createInjectedModuleBoundary(React);
export const App = () => (
<InjectedModuleBoundary
specifier="@acme/pricing-plans"
context={{ data: { locale: "en" } }}
fallback={null}
errorFallback={null}
/>
);
// dev/main.tsx — an ordinary Vite entry point, nothing RMC-specific
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(<App />);
<!-- dev/index.html — same import-map shape as the host, pointed at your local build -->
<head>
<script type="importmap">
{ "imports": { "@acme/pricing-plans": "http://localhost:5174/index.mjs" } }
</script>
</head>

Nothing here is toolkit-provided, and nothing needs to be — createInjectedModuleBoundary is doing the same job it does in the host, so there’s no separate harness mechanism that could quietly drift out of sync with production. (Whatever data the slice normally fetches over the network is the one piece left entirely to you — stub it however fits, e.g. intercepting window.fetch with sample responses in dev/main.tsx.)

One deliberate constraint: point the import map at the slice’s built output (vite build --watch serving from vite preview, or equivalent), not at a dev server serving raw source. The alias and evaluation-ordering handling from the previous section only take effect once the slice is actually built — loading unbuilt source can render fine locally and still break once it ships. This also means there’s no HMR in this loop: editing the slice’s source triggers a rebuild, but the browser needs a manual refresh to pick it up. That’s the trade for the harness genuinely testing what ships, rather than a faster but unverified stand-in for it.

Staying here is fine — or use it as a stepping stone

Section titled “Staying here is fine — or use it as a stepping stone”

If the goal was to isolate one piece — give it its own release cadence, its own repository, one less thing to coordinate — there’s no obligation to go any further. A slice built this way is a complete, working piece of Runtime Module Composition on its own, and it’s fine to leave it exactly as it is.

If you do want to go further, there’s a path off it: once a slice built this way — or the host itself — moves its own React or Vue usage onto the import-map convention too (the same @esm.sh/react-style specifier every other slice already uses), there’s no more need for the factory indirection. At that point, drop the deps bag and switch the slice to the mount/unmount convention (React or Vue), or — for a React slice specifically — the plain-component convention (createDynamicModuleBoundary; there’s no Vue equivalent of this one, see Slice conventions). createInjectedModuleBoundary makes that first extraction possible before the rest of a migration happens, if a migration is where you’re headed — it just isn’t the only reason to use it.

Written by Angelo Vagenas