DEV Community

Cover image for Web Components in Angular: Integrating React Components Across Framework Boundaries
ujjavala
ujjavala

Posted on

Web Components in Angular: Integrating React Components Across Framework Boundaries

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:

  1. <product-catalog> – Product listing
  2. <ui-dialog> – Modal dialog (with Shadow DOM and slots)
  3. <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>
)}
Enter fullscreen mode Exit fullscreen mode

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;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

<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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

  1. Web Components Are DOM Elements – treat like <video> or <canvas>.
  2. Custom Events Are Standard – native pattern for communication.
  3. Shadow DOM Rules Matter – slots, events, styles, timing.
  4. Framework Differences – Angular, React, Vue handle web components differently.
  5. 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

  1. React dispatches map:request-open
  2. Angular listens and controls dialog visibility
  3. Dialog uses slots and slotchange for dynamic content
  4. 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);
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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'
}));
Enter fullscreen mode Exit fullscreen mode

Top comments (0)