Context
Our Angular 14.3.0 app reached end-of-life in November 2023. Rather than upgrading Angular, we chose a gradual migration to React 18.2.0 using web components as a bridge. This allowed React components to run inside Angular, enabling:
- Incremental migration without a full rewrite
- Parallel development in React while maintaining Angular
- Component reuse across frameworks
Scenario
Needed three React components wrapped as web components:
-
<product-catalog>– Product listing -
<ui-dialog>– Modal dialog (with Shadow DOM and slots) -
<store-locator>– Interactive store map
The goal: clicking a product in <product-catalog> opens <ui-dialog>, which contains <store-locator> showing stores for that product. All hosted in Angular.
Challenge: Web Components Aren’t Framework Components
Web components promise framework-agnostic reuse, but integrating React components in Angular revealed hidden pitfalls:
- React treats custom element attributes as strings
- Shadow DOM isolates events and content
- React’s reconciliation can destroy web component state
Explored three approaches.
Approach 1: Direct JSX Usage
{showStoreMap && (
<Dialog open onClose={() => setShowStoreMap(false)}>
<store-locator product-id={selectedProductId} />
</Dialog>
)}
Problems:
- TypeScript required declarations for
<store-locator> - Component rendered empty in DOM with no errors
- Lifecycle mismatch: React rendered before custom element registered
- Shadow DOM prevented React events from propagating
Abandoned.
TypeScript declaration required:
declare namespace JSX {
interface IntrinsicElements {
'store-locator': {
'product-id'?: string;
'api-url'?: string;
};
}
}
Approach 2: Imperative DOM Manipulation
const locator = document.createElement('store-locator');
locator.setAttribute('product-id', productId);
locator.setAttribute('api-url', window.location.origin);
container.appendChild(locator);
Problems:
- Reopening dialog didn’t update the map
- Duplicate elements on re-render
- Manual attribute updates and event listeners required
- Bundle duplication (Dialog imported in React and Angular)
Required cleanup and updates:
useEffect(() => {
return () => {
if (storeLocatorRef.current) {
storeLocatorRef.current.remove();
storeLocatorRef.current = null;
}
};
}, []);
useEffect(() => {
if (storeLocatorRef.current && productId) {
storeLocatorRef.current.setAttribute('product-id', productId);
}
}, [productId]);
storeLocatorRef.current.addEventListener('store-selected', handleSelection);
Technically workable but heavy on boilerplate and fragile.
Approach 3: Custom Events – The Winner
Decoupled components using Custom Events.
React <product-catalog>
export const ProductCatalog = () => {
const handleViewStores = (productId) => {
const event = new CustomEvent('map:request-open', {
detail: {
productId,
productName: 'Wireless Headphones',
storeCount: 24
},
bubbles: true,
composed: true
});
document.dispatchEvent(event);
};
return (
<button onClick={() => handleViewStores(123)}>
View Store Locations
</button>
);
};
Angular Host Listener
@Component({
selector: 'app-root',
template: `
<product-catalog></product-catalog>
<ui-dialog *ngIf="storeData">
<store-locator
[attr.product-id]="storeData.productId"
[attr.api-url]="apiUrl">
</store-locator>
</ui-dialog>
`
})
export class AppComponent implements OnInit {
storeData: any = null;
apiUrl = environment.apiUrl;
ngOnInit() {
document.addEventListener('map:request-open', (event: CustomEvent) => {
this.zone.run(() => {
this.storeData = event.detail;
});
});
}
ngOnDestroy() {
document.removeEventListener('map:request-open', this.handleMapOpen);
}
}
<ui-dialog> with Shadow DOM slots and slotchange
export const DialogWebComponent = () => {
const slotRef = useRef(null);
const [content, setContent] = useState(null);
useEffect(() => {
const slot = slotRef.current;
if (!slot) return;
const processSlotContent = () => {
const elements = slot.assignedElements();
if (elements.length > 0) {
setContent(/* render elements */);
}
};
processSlotContent();
const handleSlotChange = () => {
processSlotContent();
};
slot.addEventListener('slotchange', handleSlotChange);
return () => {
slot.removeEventListener('slotchange', handleSlotChange);
};
}, []);
return (
<div className="dialog-backdrop">
<div className="dialog-content">
<slot ref={slotRef} />
</div>
</div>
);
};
Lessons Learned
-
Web Components Are DOM Elements – treat like
<video>or<canvas>. - Custom Events Are Standard – native pattern for communication.
- Shadow DOM Rules Matter – slots, events, styles, timing.
- Framework Differences – Angular, React, Vue handle web components differently.
- Component Placement Strategy – singletons in host app; reusable widgets as web components.
When to Use Web Components
Ideal: Design systems, CMS widgets, third-party integrations, small microfrontends.
Avoid: Deep state sharing, complex inter-component interactions, performance-critical applications.
Alternatives: Module Federation, iframe microfrontends, framework-specific wrappers, server-side rendering.
Final Architecture
- React dispatches
map:request-open - Angular listens and controls dialog visibility
- Dialog uses slots and
slotchangefor dynamic content - Store locator renders inside dialog
Predictable, testable, maintainable, debuggable. Web components enable framework-agnostic reuse while keeping React and Angular code cleanly separated.
Code Examples
Custom Event Communication
const ProductCard = () => (
<button onClick={() => {
document.dispatchEvent(new CustomEvent('product:view-details', {
detail: { id: 42, name: 'Wireless Mouse' }
}));
}}>
View Details
</button>
);
document.addEventListener('product:view-details', (e) => {
console.log('Product clicked:', e.detail);
});
Web Component with Slots and slotchange
class DialogElement extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div class="backdrop">
<div class="content">
<slot></slot>
</div>
</div>
`;
const slot = shadow.querySelector('slot');
slot.addEventListener('slotchange', () => {
console.log('Content changed:', slot.assignedElements());
});
}
}
customElements.define('dialog-element', DialogElement);
Imperative Web Component Creation
function createStoreLocator(config) {
const locator = document.createElement('store-locator');
locator.setAttribute('api-url', config.apiUrl);
locator.setAttribute('product-id', config.productId);
locator.addEventListener('store-selected', (e) => {
console.log('Store selected:', e.detail);
});
return locator;
}
const container = document.getElementById('map-container');
container.appendChild(createStoreLocator({
apiUrl: '/api',
productId: '123'
}));
Top comments (0)