Using Custom Elements to Isolate Component State #
When several independently deployed apps mount into one host page, their state leaks into each other through global stores, shared framework context, and bubbling DOM events — this guide shows how to wrap a component in a custom element so its state stays private and its only contract is a small, explicit API.
A custom element gives you a hard boundary the host cannot reach into. Internal state lives in private fields, the framework component mounts inside the element, and the outside world talks to it only through attributes, properties, and events. The result is a widget you can drop into a React shell, a Vue shell, or plain HTML without any of them sharing memory.
This is the DOM-level approach to cross-app state and context sharing: instead of synchronizing stores, you stop sharing state altogether and communicate over a narrow message surface.
The payoff is autonomy. A team can rewrite the internals of their widget — swap React for Svelte, restructure the store, change the rendering strategy — without touching a single line in the host, because the host never depended on anything but the attributes, properties, and events. That is the contract custom elements were designed to enforce, and it maps cleanly onto independently deployed micro-frontends where each remote owns its runtime.
Prerequisites #
- A modern evergreen browser target (Chrome 88+, Firefox 90+, Safari 16+). No custom-element polyfill is needed for these.
react@18andreact-dom@18if you follow the React mount example. The same pattern works with Vue 3 or Angular — only the mount/unmount calls change.- A bundler that can produce an entry file for the element. The snippets assume webpack 5 or Vite; either works.
- Familiarity with the Web Components lifecycle:
connectedCallback,disconnectedCallback,attributeChangedCallback, andobservedAttributes.
If you also need to keep the widget’s CSS from bleeding into the host, pair this with Shadow DOM style isolation for micro-frontends. This guide attaches a shadow root mainly to get a clean mount point and event scope.
Step 1 — Define a custom element #
Start with the minimal skeleton: a class extending HTMLElement, registered with a hyphenated tag name. The tag name must contain a dash so the browser treats it as a custom element.
// src/IsolatedWidget.ce.js
class IsolatedWidget extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `<div id="mount"></div>`;
}
}
if (!customElements.get("isolated-widget")) {
customElements.define("isolated-widget", IsolatedWidget);
}
The customElements.get guard matters in a federated setup: if two remotes ship the same tag, the second define call throws NotSupportedError. Guarding makes the registration idempotent.
Choose the tag name carefully. It is global to the page and shared across every app on it, so namespace it to your team or remote — checkout-cart-widget rather than cart — to avoid collisions with another remote that picked the same generic name. Once defined, a tag name cannot be redefined for the lifetime of the page, so treat it as a stable part of your public API.
Step 2 — Encapsulate internal state #
Hold state in a private class field so nothing outside the element can read or mutate it. Private fields (#state) are enforced by the runtime, not a convention — element.#state from the host is a syntax error.
class IsolatedWidget extends HTMLElement {
#state = { count: 0, theme: "light" };
#setState(patch) {
this.#state = { ...this.#state, ...patch };
this.#render();
}
#render() {
// re-render the mounted component with the new state (Step 4)
}
}
Every state change funnels through #setState, which is the single place that triggers a re-render. Keeping that funnel narrow is what makes the boundary predictable: there is exactly one entry point for mutation and exactly one path to the screen, so you can reason about and trace every change.
This is deliberately the opposite of a shared global store. The widget’s state is not registered anywhere, not subscribable from outside, and not serialized into any cross-app channel unless you explicitly emit it. If the host wants to know about a change, it learns through an event — never by reading the element’s memory directly.
Step 3 — Expose attributes, properties, and events as the API #
The element’s public contract is exactly three things: observed attributes (string config from markup), JS properties (rich data from the host), and events (state changes flowing out). Nothing else is reachable.
class IsolatedWidget extends HTMLElement {
#state = { count: 0, theme: "light" };
static get observedAttributes() {
return ["theme"]; // string-only, declarative config
}
attributeChangedCallback(name, _old, value) {
if (name === "theme" && value) {
this.#setState({ theme: value });
}
}
// Property API for structured data the host sets in JS
get config() {
return this.#state.config;
}
set config(value) {
this.#setState({ config: value });
}
#emit(detail) {
this.dispatchEvent(
new CustomEvent("widget:change", {
detail,
bubbles: true,
composed: true, // cross the shadow boundary
})
);
}
}
Use attributes for flat strings (theme="dark") and properties for objects or arrays — attributes coerce everything to strings, so passing an object through one forces brittle JSON.parse round-trips. Outbound changes go through #emit as a CustomEvent; composed: true lets it escape the shadow root.
Step 4 — Mount a framework component inside the element #
Mount the framework component into the shadow root’s #mount node during connectedCallback, after the markup exists. Keep the root handle so you can re-render and, crucially, unmount later.
import { createRoot } from "react-dom/client";
import { Widget } from "./Widget";
class IsolatedWidget extends HTMLElement {
#state = { count: 0, theme: "light" };
#root = null;
connectedCallback() {
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `<div id="mount"></div>`;
const mount = this.shadowRoot.getElementById("mount");
this.#root = createRoot(mount);
this.#render();
}
#render() {
this.#root?.render(
<Widget
{...this.#state}
onChange={(detail) => this.#emit(detail)}
/>
);
}
}
The component receives current state as props and reports back through the onChange callback, which the element translates into a CustomEvent. The component itself never knows it lives inside a custom element — it is just receiving props and calling a handler.
That indirection is intentional. The framework adapter lives entirely inside the element, so your component stays a plain, testable React (or Vue, or Angular) component with no Web Components knowledge baked in. For Vue you would create an app with createApp and call app.mount(mount); for Angular you would bootstrap into the mount node. In all three cases the element holds the framework’s root handle and re-renders by re-supplying props, exactly as shown here.
Step 5 — Clean up in the lifecycle callbacks #
disconnectedCallback runs when the element leaves the DOM. Unmount the framework root, drop references, and remove any listeners you added. Skipping this is the most common cause of leaks during route changes and hot reloads.
class IsolatedWidget extends HTMLElement {
#root = null;
#onResize = () => this.#render();
connectedCallback() {
// ...mount as in Step 4...
window.addEventListener("resize", this.#onResize);
}
disconnectedCallback() {
window.removeEventListener("resize", this.#onResize);
this.#root?.unmount();
this.#root = null;
}
}
Note the listener is stored as a stable bound field so removeEventListener can match it. An inline arrow passed to both add and remove would be two different functions and the listener would never detach.
Verification #
Confirm isolation and cleanup with these concrete checks.
State is private. In DevTools, grab the element and confirm the host cannot read internal state:
const el = document.querySelector("isolated-widget");
el.config = { items: 3 }; // property API works
el.shadowRoot.querySelector("#mount"); // mount point exists
// el.#state — SyntaxError: private field, unreachable
Events cross the boundary. Listen on the host and confirm the composed event arrives:
document.addEventListener("widget:change", (e) =>
console.log("received", e.detail)
);
// interact with the widget — you should see the logged detail
No leak on teardown. Open DevTools → Memory, take a heap snapshot, add and remove the element 50 times, force GC, then snapshot again. Filter for Detached nodes and your component class — counts should return to baseline. A growing count means disconnectedCallback is not unmounting.
Troubleshooting #
The host sets an object via an attribute and the widget sees [object Object].
Attributes are always strings; setAttribute("config", {...}) stringifies the object. Use the property API (el.config = {...}) for structured data and reserve observedAttributes for flat string values like theme. If markup-driven config is unavoidable, serialize to JSON in the host and JSON.parse inside attributeChangedCallback, with a try/catch fallback.
The host listener never fires.
A CustomEvent dispatched from inside a shadow root stops at the boundary unless it is both bubbles: true and composed: true. Without composed, the event is retargeted and never reaches host listeners. Verify both flags are set on the CustomEvent constructor options, and that the host listens on an ancestor of the element rather than on the element’s shadow root.
Changing an attribute does not re-render the component.
attributeChangedCallback only fires for names returned by the static observedAttributes getter, and only after connectedCallback has run. If the attribute is missing from that array, the callback is silent. Also confirm the callback routes through your single #setState/#render funnel — setting this.#state directly without calling render leaves the mounted component showing stale props.
Memory grows after repeated mount/unmount cycles.
This is almost always a teardown gap. Confirm disconnectedCallback calls root.unmount() (or the Vue/Angular equivalent), removes every listener added in connectedCallback, and clears the root reference. Listeners attached to window or document are the usual culprits because they outlive the element. For decoupling that survives teardown cleanly, route cross-app messages through an event bus with subscriptions you can dispose in the same callback.