React 128 ⚛️ Micro-Frontend Integration with Web Components
A micro-frontend is an architectural pattern where a large application is composed from independently developed, independently deployed frontend fragments. Web Components are a browser-native mechanism for encapsulating markup, styles, and behavior into a custom element. The combination is appealing because it promises framework-agnostic composition: a React shell can host a Vue micro-frontend, or a legacy Angular application can host a new React feature, without either side knowing the other’s framework. React 19’s improved custom element support makes this integration materially better than it was in React 18.
This chapter covers the integration of React with Web Components in a micro-frontend architecture. It begins with the custom element contract—attributes, properties, and events—and the React 19 rules that determine how JSX props map to each. It then addresses the two directions of integration: React consuming a Web Component, and React being packaged as a Web Component for consumption by a shell. The final sections cover the tooling that reduces the boilerplate, the Shadow DOM isolation that makes the pattern valuable, and the limitations that make it a composition tool rather than a universal solution.
Key point: A Web Component boundary is a DOM boundary, not a JavaScript boundary. The element is a black box from the outside. React 19 passes primitive props as attributes and property-shaped props as properties, but functions still require a CustomEvent dispatch. The contract is the element’s public API, not the framework’s internals.
Why Web Components for micro-frontends
The framework boundary problem. A micro-frontend architecture assumes that different teams may use different frameworks, or the same framework at different versions. If the shell and the remote share a JavaScript runtime, the shell’s framework version wins, and the remote must conform. A Web Component boundary avoids this: the custom element is registered in the browser’s custom element registry, and the shell instantiates it without importing the remote’s framework code. The remote’s React version, its state management, and its build output are encapsulated inside the element .
The React 19 improvement. React 18 had incomplete custom element support. Props were always passed as attributes, which meant objects and functions could not cross the boundary. React 19 changed the rules. On the client, a prop whose name matches a property on the element instance is assigned as a property; otherwise it is passed as an attribute. On the server, primitive props render as attributes, and non-primitive props are omitted . This is what makes passing objects and callbacks to a Web Component practical.
The style isolation requirement. A shell application and a micro-frontend have independent CSS. Without isolation, the shell’s styles leak into the remote and vice versa. Shadow DOM is the browser’s native solution: the custom element can attach a shadow root, and the styles inside the shadow root do not affect the outside document, and the outside styles do not affect the inside . This is the mechanism that makes the Web Component boundary a real boundary rather than a naming convention.
The React-as-Web-Component pattern. A React application can be wrapped in a custom element. The pattern is to create a class that extends HTMLElement, render the React tree into a container inside the element’s shadow root (or light DOM), and map the element’s attributes and properties to React props. The wrapper is the bridge between the DOM world and the React world . The shell imports the element, not the React application, and the React version inside the element is independent of the shell’s.
a. The custom element contract in React 19
The contract has three parts: attributes, properties, and events. React 19’s rules for each are the foundation of every integration.
Attributes are strings. They are set with setAttribute and appear in the DOM. In React 19, a primitive prop (string, number, boolean true) renders as an attribute on the server. On the client, a prop is passed as an attribute if it does not match a property on the element . An attribute is the right mechanism for configuration that is also meaningful in HTML: a label, a theme name, a URL.
Properties are arbitrary JavaScript values. They are set directly on the element instance and do not appear in the markup. React 19 assigns a prop as a property if the property name exists on the element instance at construction time . The element must declare the property in its constructor for React to recognize it. A property is the right mechanism for objects, arrays, and any value that cannot be serialized to a string.
// A Web Component that accepts a property.
class UserCard extends HTMLElement {
constructor() {
super();
// Declare the property so React recognizes it.
this.user = undefined;
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `<p>${this.user?.name ?? 'Guest'}</p>`;
}
}
customElements.define('user-card', UserCard);
// In React, the object is passed as a property because
// `user` exists on the instance.
<UserCard user={{ name: 'Alice' }} />
Events are dispatched with CustomEvent. React 19 listens for them with the on prefix in JSX: onMyEvent listens for a my-event event, and the casing of the event name is preserved . The event’s payload is in event.detail. This is the mechanism for passing data from the Web Component back to the React shell. A function prop cannot be passed directly; the Web Component dispatches an event, and the React side handles it.
// The Web Component dispatches a custom event.
this.dispatchEvent(new CustomEvent('save', { detail: { id: 1 }, bubbles: true }));
// React listens with the on prefix.
<UserCard onSave={(e) => console.log(e.detail.id)} />
b. React consuming a Web Component
The consumption pattern depends on whether the Web Component is a third-party element or one that the React application controls. For a third-party element, the React application registers the element’s tag name in TypeScript’s JSX namespace so that the compiler accepts it. In React 19, the namespace moved from JSX to React.JSX .
// TypeScript declaration for a custom element.
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'user-card': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & { user?: { name: string } },
HTMLElement
>;
}
}
}
For a Web Component that the React application itself defines, the @wc-toolkit/react-wrappers package generates React wrappers from a Custom Elements Manifest. The generated wrapper handles the property assignment, the event binding, and the ref forwarding, and it provides TypeScript types for the props and the event payloads . This is the practical path for a design system or a component library that is shipped as Web Components but consumed by React applications.
The integration with a micro-frontend shell adds the Module Federation layer. The shell loads the remote entry, which exposes the Web Component module, and registers the custom element. The shell then renders the element in its React tree, passing the configuration as properties and handling the events. The eesti.ee portal’s demo is a concrete example: the React micro-frontend is exposed as a Web Component via Webpack Module Federation, and the shell passes an MfeIntegration object as a property for event subscription and configuration access .
c. React as a Web Component
The reverse direction—packaging a React application as a custom element—is the pattern that allows a React micro-frontend to be consumed by a non-React shell. The wrapper class extends HTMLElement, creates a shadow root, and mounts the React tree into a container inside it.
import { createRoot, Root } from 'react-dom/client';
import App from './App';
class ReactMfeElement extends HTMLElement {
private root: Root | null = null;
constructor() {
super();
// Declare properties for React to assign.
this.apiUrl = undefined;
this.integration = undefined;
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const container = document.createElement('div');
this.shadowRoot!.appendChild(container);
this.root = createRoot(container);
this.root.render(
<App apiUrl={this.apiUrl} integration={this.integration} />
);
}
disconnectedCallback() {
// Unmount React when the element is removed.
this.root?.unmount();
this.root = null;
}
}
customElements.define('react-mfe', ReactMfeElement);
The connectedCallback is the mount point, and the disconnectedCallback is the cleanup. The properties are declared in the constructor so that React (or any consumer) can assign them. The shadow root provides the style isolation . The React application inside the element is unaware that it is running inside a Web Component; it renders normally, and the wrapper handles the boundary.
The build configuration exposes the wrapper as the entry point. With Webpack Module Federation, the module is exposed as ./web-components, and the shell imports it dynamically. With Vite, the library mode produces an ES module that the shell can import, and the custom element is registered as a side effect.
Complete Example Session
// ============================================
// PART 1: THE WEB COMPONENT CONTRACT
// ============================================
// Attributes are strings. Properties are arbitrary.
// Events are CustomEvents with detail payloads.
// ============================================
// PART 2: THE REACT CONSUMER
// ============================================
// React 19 assigns properties for declared names.
// Events use the on prefix.
function Shell() {
return (
<user-card
theme="dark" // attribute
user={{ name: 'Alice' }} // property
onSave={(e) => console.log(e.detail)} // CustomEvent
/>
);
}
// ============================================
// PART 3: THE TYPESCRIPT DECLARATION
// ============================================
// React 19 moved JSX namespace to React.JSX.
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'user-card': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & { user?: { name: string } },
HTMLElement
>;
}
}
}
// ============================================
// PART 4: THE REACT-AS-WEB-COMPONENT WRAPPER
// ============================================
// Mount React inside the custom element.
import { createRoot, Root } from 'react-dom/client';
class ReactMfe extends HTMLElement {
private root: Root | null = null;
apiUrl: string | undefined;
integration: unknown;
constructor() {
super();
this.apiUrl = undefined;
this.integration = undefined;
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const container = document.createElement('div');
this.shadowRoot!.appendChild(container);
this.root = createRoot(container);
this.root.render(<App apiUrl={this.apiUrl} integration={this.integration} />);
}
disconnectedCallback() {
this.root?.unmount();
this.root = null;
}
}
customElements.define('react-mfe', ReactMfe);
// ============================================
// PART 5: THE MODULE FEDERATION EXPOSURE
// ============================================
// webpack.config.js exposes the wrapper module.
// module.exports = {
// output: { publicPath: 'auto' },
// plugins: [
// new ModuleFederationPlugin({
// name: 'reactMfe',
// filename: 'remoteEntry.js',
// exposes: { './web-components': './src/web-component.tsx' },
// }),
// ],
// };
// ============================================
// PART 6: THE SHELL CONSUMPTION
// ============================================
// The shell loads the remote and renders the element.
async function loadRemote() {
await import('reactMfe/web-components');
// The custom element is now registered.
}
function ShellApp() {
return (
<react-mfe
api-url="https://api.example.com"
integration={integrationObject}
onLanguageChange={(e) => setLanguage(e.detail)}
/>
);
}
// ============================================
// PART 7: THE SHADOW DOM ISOLATION
// ============================================
// Styles inside the shadow root do not leak out.
// Styles outside do not leak in.
// Inside the wrapper:
// this.attachShadow({ mode: 'open' });
// <style> inside shadowRoot applies only there.
// ============================================
// PART 8: THE GENERATED WRAPPERS
// ============================================
// @wc-toolkit/react-wrappers generates typed wrappers.
// npx cem analyze
// → ./react/MyButton.tsx with typed props and events
// ============================================
// PART 9: THE LIMITATIONS
// ============================================
// Functions cannot be passed directly.
// Use CustomEvent dispatch instead.
// SSR omits non-primitive props.
// Shadow DOM blocks some global styles.
// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// The Web Component is the boundary.
// React 19 handles the property/attribute split.
// The wrapper handles the mount/unmount.
export default function App() {
return <Shell />;
}
The ten parts build the contract, the consumer, the wrapper, and the federation exposure. The pattern is consistent: the Web Component is the boundary, and React 19’s property rules make the crossing practical.
Quick Reference
React 19 Custom Element Rules
| Prop Type | Client Behavior | Server Behavior |
|---|---|---|
| String | Attribute | Attribute |
| Number | Attribute | Attribute |
true | Attribute | Attribute |
false | Attribute | Omitted |
| Object | Property if declared | Omitted |
| Function | Not supported | Not supported |
Event Binding
| JSX | Event Listened |
|---|---|
onSave | save |
onMyEvent | myEvent |
onsay-hi | say-hi |
Integration Directions
| Direction | Pattern |
|---|---|
| React consumes Web Component | JSX tag + property/event mapping |
| React packaged as Web Component | HTMLElement wrapper + createRoot |
| Shell loads remote | Module Federation exposes |
| Typed wrappers | @wc-toolkit/react-wrappers |
Shadow DOM
| Feature | Effect |
|---|---|
attachShadow({ mode: 'open' }) | Creates isolated style scope |
| Styles inside shadow root | Do not leak to document |
| Styles outside | Do not leak into shadow root |
Best Practices
✅ Do This:
// Declare properties in the constructor.
this.user = undefined; // ✅
// Use CustomEvent for data flowing back to React.
this.dispatchEvent(new CustomEvent('save', { detail: data })); // ✅
// Listen with the on prefix and preserved casing.
<UserCard onSave={(e) => handle(e.detail)} /> // ✅
// Use Shadow DOM for style isolation.
this.attachShadow({ mode: 'open' }); // ✅
// Unmount React in disconnectedCallback.
this.root?.unmount(); // ✅
❌ Don’t Do This:
// Don't pass a function as a prop directly.
<UserCard onSave={fn} /> // works only if dispatched as event // ❌
// Don't expect non-primitive props in SSR.
<UserCard user={{}} /> // omitted on server // ❌
// Don't use the old JSX namespace.
declare namespace JSX { ... } // React 19 moved to React.JSX // ❌
// Don't forget the property declaration.
this.user = undefined; // without it, React passes as attribute // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Object passed as string | Property not declared in constructor | Declare the property |
| Event handler not firing | Event name casing mismatch | Preserve casing in on prefix |
| SSR hydration mismatch | Non-primitive props omitted on server | Guard with client-only rendering |
| Styles leak | Shadow DOM not used | attachShadow in constructor |
| TypeScript error | JSX namespace location | Use declare module 'react' |
| Memory leak | React not unmounted | disconnectedCallback cleanup |
Real-World Examples
1. Attribute Pass
<user-card theme="dark" />
2. Property Pass
<user-card user={{ name: 'Alice' }} />
3. Event Listen
<user-card onSave={(e) => console.log(e.detail)} />
4. Dispatch Event
this.dispatchEvent(new CustomEvent('save', { detail: data, bubbles: true }));
5. React Wrapper
class ReactMfe extends HTMLElement { connectedCallback() { createRoot(...).render(<App />); } }
6. Module Federation Expose
exposes: { './web-components': './src/web-component.tsx' }
7. Shadow Root
this.attachShadow({ mode: 'open' });
8. TypeScript Declaration
declare module 'react' { namespace JSX { interface IntrinsicElements { ... } } }
9. Generated Wrapper
npx cem analyze # → ./react/MyButton.tsx
10. Cleanup
disconnectedCallback() { this.root?.unmount(); }
Visual
The Custom Element Contract
┌─────────────────────────────────────────────────────────┐
│ REACT SHELL │
│ │
│ <user-card │
│ theme="dark" ← attribute (string) │
│ user={{ name: 'A' }} ← property (object) │
│ onSave={fn} ← CustomEvent listener │
│ /> │
│ │ │
│ ▼ │
│ CUSTOM ELEMENT (black box) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Shadow DOM │ │
│ │ <style>...</style> ← isolated styles │ │
│ │ <div>...</div> ← isolated markup │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │
│ ▼ dispatchEvent(new CustomEvent('save')) │
│ REACT SHELL receives e.detail │
└─────────────────────────────────────────────────────────┘
React 19 Prop Handling
┌─────────────────────────────────────────────────────────┐
│ CLIENT │
│ │
│ Does the prop name exist on the element instance? │
│ │ │
│ YES │ NO │
│ ▼ ▼ │
│ Property Attribute │
│ (any value) (string only) │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ SERVER (SSR) │
│ │
│ Primitive (string, number, true)? │
│ │ │
│ YES │ NO │
│ ▼ ▼ │
│ Attribute Omitted │
└─────────────────────────────────────────────────────────┘
The Micro-Frontend Architecture
┌─────────────────────────────────────────────────────────┐
│ SHELL (React or other) │
│ │ │
│ ▼ Module Federation remoteEntry │
│ ┌───────────────────────────────────────────────────┐ │
│ │ REMOTE ENTRY │ │
│ │ exposes './web-components' │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │
│ ▼ customElements.define('react-mfe') │
│ ┌───────────────────────────────────────────────────┐ │
│ │ <react-mfe> │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ Shadow DOM │ │ │
│ │ │ React root (independent version) │ │ │
│ │ │ App.tsx │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
The Shadow DOM Isolation
┌─────────────────────────────────────────────────────────┐
│ DOCUMENT │
│ <style> .button { color: red; } </style> │
│ │ │
│ │ Does not affect shadow content │
│ ▼ │
│ <react-mfe> │
│ ┌───────────────────────────────────────────────────┐ │
│ │ SHADOW ROOT │ │
│ │ <style> .button { color: blue; } </style> │ │
│ │ <button class="button">Blue</button> │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ Shadow styles do not leak to the document. │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Custom element recognition | Tag with a dash |
| Attribute | String, set with setAttribute |
| Property | Arbitrary value, declared in constructor |
| Event | CustomEvent with detail |
| React 19 client rule | Property if name exists on instance |
| React 19 server rule | Attribute if primitive, omitted otherwise |
| Event binding | on prefix, casing preserved |
| Shadow DOM | attachShadow({ mode: 'open' }) |
| React wrapper | HTMLElement + createRoot |
| Federation | exposes in Module Federation config |
Key takeaways:
- React 19’s custom element support is the practical enabler. Properties are assigned for declared names, events are bound with the
onprefix, and objects can cross the boundary on the client . - The Web Component boundary is a DOM boundary. The element is a black box. Its API is attributes, properties, and events, not props and callbacks .
- Shadow DOM is the isolation mechanism. It prevents style leakage in both directions and makes the micro-frontend boundary a real boundary rather than a convention.
- A React application can be packaged as a Web Component. The wrapper extends
HTMLElement, creates a shadow root, mounts React withcreateRoot, and unmounts indisconnectedCallback. - Module Federation is the distribution layer. The remote exposes the wrapper module, and the shell imports it and renders the custom element. The React version inside the element is independent of the shell’s .
- Functions cannot be passed directly. A function prop is not supported. The Web Component dispatches a
CustomEvent, and the React side handles it with theonprefix . - SSR omits non-primitive props. On the server, only primitive props render as attributes. Objects and functions are omitted, which means the initial HTML may lack configuration that the client provides on hydration .
- Generated wrappers reduce the boilerplate.
@wc-toolkit/react-wrappersproduces typed React components from a Custom Elements Manifest, handling property assignment and event binding .
Remember: A Web Component is a browser primitive, not a React abstraction. The element is registered in the custom element registry, and React renders it as a DOM tag with a dash. React 19’s rules determine how props become attributes or properties, and events are the mechanism for data flowing back. Shadow DOM provides the isolation that makes the boundary valuable. The integration is a composition of two independent systems, and the success of the composition depends on the clarity of the contract between them.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!