Custom Elements for State Encapsulation #
When several teams ship independent micro-frontends into one page, the failure mode is rarely the happy path. It is the slow erosion of boundaries: a remote mutates a global the host owns, a stylesheet from one team bleeds into another team’s widget, and a refactor in a remote silently breaks a host that reached into its internals. The symptoms show up as flaky integration tests, “works in isolation but not in the shell” bugs, and a state model nobody can fully reason about.
Custom elements give you a hard boundary the browser itself enforces. A remote ships a tag — <orders-panel> — and the host treats it as an opaque node: it pushes data in through attributes and properties, and it listens for events coming out. The remote’s internal state, framework, and DOM live behind a Shadow DOM wall that styles and scripts on the outside cannot reach.
This guide belongs to the broader work of Cross-App State & Context Sharing, and it sits alongside framework-level approaches like shared context providers and other alternatives to prop drilling. Here we go deep on the custom-element contract end to end — defining the wrapper, wiring it into a host, surviving the edge cases, testing the boundary, and shipping it. Two companion guides drill into the parts that deserve their own treatment: isolating component state inside a custom element and Shadow DOM style isolation for micro-frontends.
Key objectives #
- Define a single, framework-agnostic custom element that a remote exposes and a host mounts without knowing its internals.
- Move data in through attributes and properties, and data out through
CustomEvent, so the contract is the DOM, not shared globals. - Use a shadow root to keep the remote’s state, DOM, and styles from colliding with the host or sibling remotes.
- Survive the realities of distributed loading: async registration, non-serializable payloads, and version skew between host and remote.
- Make the boundary testable and observable so a contract break is caught in CI, not in production.
Setup & configuration #
The unit of work is the element class. Keep it small: it owns a shadow root, reflects a known set of attributes, exposes a typed property for richer data, and emits events. Everything framework-specific renders inside the shadow root and never escapes.
Defining a custom element wrapper #
// src/orders-panel.ts
interface OrdersState {
userId: string;
theme: 'light' | 'dark';
items: Array<{ id: string; total: number }>;
}
export class OrdersPanel extends HTMLElement {
// Only string-friendly config belongs in attributes.
static get observedAttributes(): string[] {
return ['user-id', 'theme'];
}
#state: OrdersState | null = null;
#root: ShadowRoot;
constructor() {
super();
// Attach once, in the constructor, so the boundary exists before any render.
this.#root = this.attachShadow({ mode: 'open' });
}
// Rich/non-serializable data comes in as a property, not an attribute.
get data(): OrdersState | null {
return this.#state;
}
set data(next: OrdersState | null) {
this.#state = next;
this.#render();
}
connectedCallback(): void {
this.#render();
}
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
if (!this.#state) return;
if (name === 'theme' && value) {
this.#state = { ...this.#state, theme: value as OrdersState['theme'] };
this.#render();
}
}
#render(): void {
const s = this.#state;
this.#root.innerHTML = `
<style>
:host { display: block; color: var(--mfe-fg, #10324d); }
.total { font-weight: 600; }
</style>
<section>
${s ? s.items.map((i) => `<div class="total">${i.id}: ${i.total}</div>`).join('') : ''}
</section>
`;
// Out-bound contract: announce changes; composed lets it cross the shadow edge.
this.dispatchEvent(
new CustomEvent('orders:changed', { detail: s, bubbles: true, composed: true })
);
}
}
Two design choices matter most. Attaching the shadow root in the constructor means the encapsulation boundary is real before a single byte renders. And splitting observedAttributes (string config) from a data property (rich state) gives you a contract that does not force everything through JSON.stringify.
Registration is a separate, idempotent step. Defining a tag twice throws, so guard it — remotes are loaded more than once in development and across hot reloads.
// src/register.ts
import { OrdersPanel } from './orders-panel';
export function register(tag = 'orders-panel'): void {
if (!customElements.get(tag)) {
customElements.define(tag, OrdersPanel);
}
}
On the remote build, expose the registration entry — not the bare class — so the act of importing the module also defines the element.
// webpack.config.js (remote)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'orders_remote',
filename: 'remoteEntry.js',
exposes: {
// Importing this module calls register() as a side effect.
'./OrdersPanel': './src/register.ts',
},
shared: {
// Share a framework only if the remote actually renders with one.
// The custom-element boundary means you can also share nothing.
lit: { singleton: true, requiredVersion: '^3.0.0' },
},
}),
],
};
Because the host only ever sees a tag name, the shared block is far less load-bearing than with direct module imports. A remote that renders with vanilla DOM inside its shadow root can share nothing at all and still co-exist with a React host — one of the quiet wins of this pattern.
Sharing styles with constructable stylesheets #
Inlining a <style> block in every render works, but it re-parses CSS on each instance and ships duplicate text in the DOM. The current approach is a constructable stylesheet shared via adoptedStyleSheets — the browser parses the rules once and every shadow root references the same object.
// src/orders-styles.ts
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host { display: block; color: var(--mfe-fg, #10324d); }
.total { font-weight: 600; }
::part(row) { padding: 4px 0; }
`);
export const ordersStyles = sheet;
// inside OrdersPanel
constructor() {
super();
this.#root = this.attachShadow({ mode: 'open' });
// One parsed sheet, adopted by every instance — no per-element <style> cost.
this.#root.adoptedStyleSheets = [ordersStyles];
}
The ::part(row) rule above is also the styling contract: anything the remote marks with part="row" becomes a hook the host can theme from the outside, without the host ever piercing the shadow boundary. Pair that with CSS custom properties (which do inherit through the shadow edge) and you have a deliberate, narrow surface for theming — covered fully in Shadow DOM style isolation for micro-frontends.
Mounting a React or Vue component inside the element #
The element class is the contract; what renders behind it is the remote’s choice. To ship an existing React widget, mount it into the shadow root in connectedCallback and tear it down in disconnectedCallback — skipping the unmount is the single most common memory leak in this pattern, because the shadow tree is removed but the framework’s reconciler keeps the subscriptions alive.
// src/orders-panel.tsx
import { createRoot, Root } from 'react-dom/client';
import { OrdersWidget } from './OrdersWidget';
import { ordersStyles } from './orders-styles';
export class OrdersPanel extends HTMLElement {
static get observedAttributes() { return ['user-id', 'theme']; }
#root: ShadowRoot;
#reactRoot: Root | null = null;
#mount: HTMLDivElement;
#state: OrdersState | null = null;
constructor() {
super();
this.#root = this.attachShadow({ mode: 'open' });
this.#root.adoptedStyleSheets = [ordersStyles];
this.#mount = document.createElement('div');
this.#root.appendChild(this.#mount);
}
connectedCallback() {
// Create the React root once the element is in the document.
this.#reactRoot = createRoot(this.#mount);
this.#render();
}
disconnectedCallback() {
// Unmount so React releases listeners, timers, and store subscriptions.
this.#reactRoot?.unmount();
this.#reactRoot = null;
}
set data(next: OrdersState | null) {
this.#state = next;
this.#render();
}
#emit = (detail: OrdersState) =>
this.dispatchEvent(
new CustomEvent('orders:changed', { detail, bubbles: true, composed: true })
);
#render() {
if (!this.#reactRoot) return;
this.#reactRoot.render(<OrdersWidget state={this.#state} onChange={this.#emit} />);
}
}
Two practical notes. React events delegated to document historically broke inside shadow roots; React 17+ attaches listeners to the React root container instead, so a root created with createRoot(this.#mount) keeps synthetic events working inside the shadow tree. And the React component talks to the host only through the onChange callback the wrapper turns into a CustomEvent — it never touches the host directly, which is what keeps the boundary honest. The Vue equivalent calls createApp(OrdersWidget).mount(this.#mount) in connectedCallback and app.unmount() in disconnectedCallback.
Form participation with ElementInternals #
When the remote’s element is a form field — a date range, a picker, a payment widget — reflecting its value as an attribute is not enough; native <form> submission and validation will ignore it. ElementInternals makes a custom element a first-class form control, so the host’s own form sees the value without reaching into the remote.
export class RangePicker extends HTMLElement {
static formAssociated = true; // opt in to form participation
#internals: ElementInternals;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.#internals = this.attachInternals();
}
set value(v: string) {
// The host form now collects this value on submit, no extra wiring.
this.#internals.setFormValue(v);
if (!v) this.#internals.setValidity({ valueMissing: true }, 'Pick a range');
else this.#internals.setValidity({});
}
}
This keeps the host’s form logic generic: it submits, validates, and resets the remote control through the standard form API rather than a bespoke per-remote contract.
Integration #
The host’s job is deliberately dull: load the remote (which registers the tag), create the element, push data in, and listen for events out. It never imports the remote’s classes or reaches into its DOM.
// host/mount-orders.ts
async function mountOrders(container: HTMLElement): Promise<void> {
// Importing the exposed module registers the custom element.
await import('orders_remote/OrdersPanel');
// Defend against load order: wait until the tag is actually defined.
await customElements.whenDefined('orders-panel');
const el = document.createElement('orders-panel') as HTMLElement & {
data: unknown;
};
// String config via attributes...
el.setAttribute('theme', 'dark');
// ...rich state via the property (no serialization).
el.data = { userId: '123', theme: 'dark', items: [{ id: 'A-1', total: 42 }] };
// Listen before appending so the first emitted event is not missed.
el.addEventListener('orders:changed', (e) => {
const detail = (e as CustomEvent).detail;
// Forward into the host store, or relay onto a shared bus.
console.log('orders changed', detail);
});
container.appendChild(el);
}
For React or Vue hosts, wrap this in a thin component that sets ref.current.data in an effect and attaches the listener — frameworks set DOM properties on custom elements inconsistently, so an explicit ref assignment is the reliable path.
This is where custom elements complement, rather than compete with, the other patterns in this area. The element is a clean boundary; how data travels between boundaries is a separate decision. Many teams pair the custom-element wall with event bus patterns for decoupled apps, turning each CustomEvent the element emits into a message other remotes can subscribe to without any direct coupling.
Edge cases #
Non-serializable state. Attributes are strings only. Dates, Maps, functions, and class instances will not survive JSON.stringify/parse. Route anything richer than a primitive through the data property, and keep attributes for flat config like theme or user-id.
Async registration races. The host may create the element before the remote bundle finishes loading, producing an inert HTMLUnknownElement upgrade. Always gate mounting behind customElements.whenDefined(tag), and set the data property after the element upgrades — properties set on an undefined element are dropped unless you implement property lazy-upgrade handling.
Partial loads and timeouts. If the remote entry fails, the tag never defines and the element stays empty. Wrap the dynamic import in a timeout and render a host-owned fallback so a single slow remote does not block the shell.
Version skew. A remote can change its event payload shape between deploys. Treat the event detail as an external API: version it (detail.version), and have the host tolerate unknown fields rather than assume an exact shape.
Framework property quirks. React (pre-19) assigns most values as attributes, stringifying objects. Use a ref to assign el.data directly, or adopt a wrapper that knows which keys are properties.
Event retargeting. A composed event that crosses the shadow edge is retargeted: by the time the host’s listener runs, event.target points at the custom element, not the inner button that fired it. This is correct and desirable — the host should not depend on the remote’s internal node — but it surprises teams who try to read event.target.dataset for inner details. Put everything the host needs in event.detail; treat event.target as “the element”, never “the clicked node”. If you genuinely need the original path during the remote’s own handling, use event.composedPath() before the event leaves the shadow root.
Focus and tab order. Focus does cross the shadow boundary for natively focusable elements, but document.activeElement reports the host custom element, not the inner control; read el.shadowRoot.activeElement to find the real one. If the element wraps non-native interactive nodes, make it focusable and forward focus by setting this.#internals.role and a tabindex, or delegate it with attachShadow({ mode: 'open', delegatesFocus: true }) so clicking the element moves focus to the first focusable descendant — important for keyboard users moving across remotes.
Third-party libraries appending to document.body. Many UI libraries (tooltips, modals, date pickers, toasts) portal their overlays to document.body, which is outside the shadow root. Those nodes lose the shadow root’s adoptedStyleSheets and pick up the host’s global CSS instead — the classic “the dropdown is unstyled only in the shell” bug. Configure the library to render into a container inside the shadow root (most expose an appendTo/container/getPopupContainer option), or render overlays into a shadow-local element you own. The same rule applies to global event listeners the library binds on document: they see retargeted events, so feature-detect rather than assume inner targets.
SSR and declarative shadow DOM. A custom element that only renders client-side flashes empty until its bundle loads and upgrades. Declarative shadow DOM lets the server emit the shadow tree as static HTML so content paints before any JavaScript runs:
<orders-panel user-id="123">
<template shadowrootmode="open">
<style>.total{font-weight:600}</style>
<section><div class="total">A-1: 42</div></section>
</template>
</orders-panel>
When the element later upgrades, detect the pre-rendered tree with this.shadowRoot already being non-null (the parser attached it) instead of calling attachShadow again, which would throw. Hydrate into the existing nodes rather than replacing innerHTML, or you re-introduce the flash you were trying to avoid.
Testing & validation #
Test the boundary, not the internals. The contract is: attributes/properties in, events out, no leakage across the shadow edge.
// orders-panel.test.ts (@web/test-runner + @open-wc/testing)
import { fixture, html, expect, oneEvent } from '@open-wc/testing';
import './src/register';
it('emits orders:changed across the shadow boundary', async () => {
const el = await fixture(html`<orders-panel theme="dark"></orders-panel>`);
const listener = oneEvent(el, 'orders:changed');
(el as any).data = { userId: '1', theme: 'dark', items: [{ id: 'A', total: 9 }] };
const event = await listener;
expect(event.detail.items).to.have.lengthOf(1);
});
it('does not leak internal styles to the light DOM', async () => {
const el = await fixture(html`<orders-panel></orders-panel>`);
// Internal nodes live in the shadow root, never the light tree.
expect(el.querySelector('.total')).to.equal(null);
expect(el.shadowRoot!.querySelector('section')).to.exist;
});
In CI, run these in a real headless browser (@web/test-runner with Playwright) rather than JSDOM, which only partially implements custom elements and Shadow DOM. Add a contract test that asserts the event detail shape, so a remote that changes its payload fails the host’s pipeline before merge. For deeper boundary assertions, the isolating component state guide covers state-leak checks in detail.
Deployment #
Ship the remote entry to a CDN with immutable, hashed filenames ([name].[contenthash].js) so an old host never hot-swaps an incompatible remote mid-session. The host resolves the current remoteEntry.js through a small manifest it fetches at startup, which lets you roll forward and back without redeploying the host.
Roll changes out behind a flag: serve a new remote URL to a slice of traffic, watch the host’s error telemetry, and widen only when clean. Because the element emits a versioned event, the host can detect a payload it does not understand and quietly fall back to its placeholder instead of throwing.
Instrument the boundary. Record time-to-define (whenDefined resolution), upgrade failures, and counts of unhandled event versions. These three signals tell you whether a remote is loading, whether it is rendering, and whether its contract still matches what the host expects — the only questions that matter when a federated page misbehaves in production.
Common pitfalls #
| Issue | Root cause & resolution |
|---|---|
Object passed via attribute arrives as [object Object] |
Attributes coerce to strings. Move rich data to the data property; reserve attributes for flat config. |
data set before element upgrades is ignored |
Property was assigned to an undefined element. Await customElements.whenDefined(tag) before setting properties, or implement lazy-upgrade in the constructor. |
customElements.define throws on reload |
The tag is already registered. Guard with if (!customElements.get(tag)) in an idempotent register(). |
| Host CSS cannot style the widget (or theme is lost) | Shadow DOM blocks inherited styles. Expose ::part() hooks and CSS custom properties (e.g. --mfe-fg) as the styling contract; see the Shadow DOM isolation guide. |
| Events never reach the host listener | A CustomEvent without composed: true cannot cross the shadow edge. Dispatch with { bubbles: true, composed: true }. |
| Host breaks after a remote deploy | Event payload shape changed. Version detail, tolerate unknown fields, and add a contract test in CI. |
| Framework root keeps running after the element is removed | disconnectedCallback never unmounted it. Call root.unmount() / app.unmount() there to release subscriptions and timers. |
attachShadow throws on a server-rendered element |
Declarative shadow DOM already attached the root. Check this.shadowRoot first and hydrate into it instead of re-attaching. |
| Tooltip/modal is unstyled only inside the shell | The library portals overlays to document.body, outside the shadow root. Point its container option at a node inside the shadow root. |
event.target is the element, not the clicked button |
Composed events are retargeted at the boundary. Read details from event.detail; use composedPath() inside the remote if needed. |
| Custom element value missing from a host form submit | Element is not form-associated. Set static formAssociated = true and report the value via ElementInternals.setFormValue. |
FAQ #
Do custom elements replace a global state manager like Redux?
No — they solve a different problem. Custom elements encapsulate a remote’s local state and DOM behind a hard boundary; a global store coordinates shared application state. Many teams use both: the element keeps its internals private and forwards events to a store, or you reach for synchronizing Redux across micro-frontends when truly shared state is unavoidable.
Can a React or Vue remote ship as a custom element?
Yes. The element class mounts the framework inside its shadow root in connectedCallback and unmounts in disconnectedCallback. The host stays framework-agnostic because it only ever touches the tag, its attributes, its data property, and its events.
How do I pass data the host cannot serialize?
Use a property rather than an attribute. Attributes are strings; properties accept any JavaScript value, including objects, Maps, and callbacks. Keep attributes for flat config and the data property for everything else.
What is the cost of a shadow root per element?
Negligible for normal counts — a few milliseconds of setup amortized across the element’s life, in exchange for guaranteed style and DOM isolation. If you render thousands of identical elements, share styles with adoptedStyleSheets instead of inlining a <style> per instance.
Can I server-render a custom-element remote so it does not flash empty?
Yes — emit declarative shadow DOM. The server writes a <template shadowrootmode="open"> inside the tag, so the browser paints the shadow tree before any JavaScript loads. On upgrade, hydrate into the existing this.shadowRoot rather than re-attaching it. This is the only first-class way to get above-the-fold content from a custom-element remote without a client-side render flash.