Web Components in React 19: Cross-Framework Compatibility

9 min read1697 words

Our design system team faced a frustrating reality: we maintained three separate component libraries for React, Vue, and Angular applications. Every button update meant three PRs, three review cycles, and inevitable inconsistencies. When React 19 shipped with native Web Components support, we consolidated everything into a single standards-based library. Here's exactly how we did it and what we learned.

The Multi-Framework Component Problem

Before React 19, using Web Components in React required painful workarounds. Complex data types like objects and arrays couldn't pass through props without useRef hacks. Custom events needed manual event listener setup in useEffect. Server-side rendering was a minefield of hydration mismatches.

I've watched teams maintain wrapper libraries just to use their own design system components in React. Stencil.js, FAST, and Lit all generated React-specific outputs. The overhead was substantial - our team spent roughly 30% of component development time on framework-specific adaptations.

React 19 changes this fundamentally. The framework now scores 100% on the Custom Elements Everywhere benchmark, meaning Web Components work as naturally as native React components.

How React 19 Handles Web Components

React 19 intelligently distinguishes between properties and attributes when passing data to custom elements. Here's the behavior:

Property Detection: React checks if a prop name matches an existing property on the DOM element. If it does, React sets it as a property (enabling complex objects). If not, it falls back to an HTML attribute.

Boolean Attributes: Boolean values dynamically add or remove attributes. disabled={true} adds the attribute, disabled={false} removes it.

Custom Events: Standard onEventName syntax works without configuration. React automatically converts camelCase handlers to the appropriate event listeners.

SSR Compatibility: Primitive values (strings, numbers, true) render as attributes server-side. Complex values (objects, functions, false) are omitted during SSR and applied client-side after hydration.

Building a Reusable Web Component

Let me walk through creating a notification badge component that works across frameworks. This pattern applies to any component you'd typically share.

// notification-badge.ts
class NotificationBadge extends HTMLElement {
  static observedAttributes = ['count', 'variant'];
 
  private shadow: ShadowRoot;
 
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
    this.render();
  }
 
  connectedCallback() {
    this.shadow.querySelector('.badge')?.addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('badge-click', {
        bubbles: true,
        composed: true,
        detail: { count: this.count }
      }));
    });
  }
 
  attributeChangedCallback() {
    this.render();
  }
 
  get count(): number {
    return parseInt(this.getAttribute('count') || '0', 10);
  }
 
  set count(value: number) {
    this.setAttribute('count', String(value));
  }
 
  get variant(): string {
    return this.getAttribute('variant') || 'default';
  }
 
  set variant(value: string) {
    this.setAttribute('variant', value);
  }
 
  private render() {
    const variantStyles: Record<string, string> = {
      default: 'background: #6b7280; color: white;',
      success: 'background: #10b981; color: white;',
      warning: 'background: #f59e0b; color: black;',
      error: 'background: #ef4444; color: white;'
    };
 
    this.shadow.innerHTML = `
      <style>
        .badge {
          display: inline-flex;
          align-items: center;
          justify-content: center;
          min-width: 20px;
          height: 20px;
          padding: 0 6px;
          border-radius: 10px;
          font-size: 12px;
          font-weight: 600;
          cursor: pointer;
          transition: transform 0.15s ease;
          ${variantStyles[this.variant] || variantStyles.default}
        }
        .badge:hover {
          transform: scale(1.1);
        }
        .badge:empty {
          display: none;
        }
      </style>
      <span class="badge">${this.count > 99 ? '99+' : this.count || ''}</span>
    `;
  }
}
 
customElements.define('notification-badge', NotificationBadge);

Register this component once at your application's entry point:

// main.tsx or _app.tsx
import './components/notification-badge';

Using Web Components in React 19

With the component registered, usage in React 19 feels native:

// NotificationPanel.tsx
import { useState } from 'react';
 
interface Notification {
  id: string;
  title: string;
  unread: number;
  type: 'default' | 'success' | 'warning' | 'error';
}
 
export function NotificationPanel() {
  const [notifications, setNotifications] = useState<Notification[]>([
    { id: '1', title: 'Messages', unread: 5, type: 'default' },
    { id: '2', title: 'Alerts', unread: 127, type: 'error' },
    { id: '3', title: 'Updates', unread: 3, type: 'success' }
  ]);
 
  const handleBadgeClick = (e: CustomEvent, id: string) => {
    console.log(`Badge clicked with count: ${e.detail.count}`);
    setNotifications(prev =>
      prev.map(n => n.id === id ? { ...n, unread: 0 } : n)
    );
  };
 
  return (
    <div className="space-y-4">
      {notifications.map(notification => (
        <div key={notification.id} className="flex items-center gap-3">
          <span className="font-medium">{notification.title}</span>
          <notification-badge
            count={notification.unread}
            variant={notification.type}
            onBadge-click={(e: CustomEvent) => handleBadgeClick(e, notification.id)}
          />
        </div>
      ))}
    </div>
  );
}

Notice how count passes as a number property, variant as a string, and the custom event handler uses React's standard on prefix with the event name. No refs, no useEffect wrappers, no special handling.

Passing Complex Data Types

React 18 required workarounds for passing objects or arrays to Web Components. React 19 handles this naturally:

// ThemeableCard.tsx
interface Theme {
  primary: string;
  secondary: string;
  borderRadius: number;
}
 
export function ThemeableCard() {
  const customTheme: Theme = {
    primary: '#3b82f6',
    secondary: '#1e40af',
    borderRadius: 8
  };
 
  const cardData = {
    title: 'Dashboard Stats',
    metrics: [
      { label: 'Users', value: 1234 },
      { label: 'Revenue', value: 56789 }
    ]
  };
 
  return (
    <themeable-card
      theme={customTheme}
      data={cardData}
      onCard-action={(e: CustomEvent) => console.log(e.detail)}
    />
  );
}

React 19 detects that theme and data are objects and sets them as properties on the DOM element rather than stringifying them as attributes. This happens automatically based on property detection.

TypeScript Integration

For proper type checking, extend the JSX namespace:

// types/web-components.d.ts
declare namespace JSX {
  interface IntrinsicElements {
    'notification-badge': React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement> & {
        count?: number;
        variant?: 'default' | 'success' | 'warning' | 'error';
        'onBadge-click'?: (event: CustomEvent<{ count: number }>) => void;
      },
      HTMLElement
    >;
    'themeable-card': React.DetailedHTMLProps<
      React.HTMLAttributes<HTMLElement> & {
        theme?: { primary: string; secondary: string; borderRadius: number };
        data?: { title: string; metrics: Array<{ label: string; value: number }> };
        'onCard-action'?: (event: CustomEvent) => void;
      },
      HTMLElement
    >;
  }
}

This gives you autocomplete, type checking, and prevents typos in prop names.

Server-Side Rendering Considerations

React 19's SSR handling for Web Components follows specific rules:

Primitives render as attributes: Strings, numbers, and true boolean values appear in the server-rendered HTML as attributes.

Complex values are client-only: Objects, arrays, functions, and false booleans don't appear in SSR output. React applies them as properties after hydration.

// This component SSR renders correctly
<notification-badge
  count={5}           // Renders as attribute: count="5"
  variant="error"     // Renders as attribute: variant="error"
  hidden={false}      // Omitted during SSR, applied client-side
/>

For Web Components that require client-side JavaScript to function, use the 'use client' directive or dynamic imports:

// ClientOnlyBadge.tsx
'use client';
 
import { useEffect, useState } from 'react';
 
export function ClientOnlyBadge({ count }: { count: number }) {
  const [mounted, setMounted] = useState(false);
 
  useEffect(() => {
    import('./notification-badge');
    setMounted(true);
  }, []);
 
  if (!mounted) {
    return <span className="badge-placeholder">{count}</span>;
  }
 
  return <notification-badge count={count} />;
}

Cross-Framework Component Library Architecture

Our team consolidated three framework-specific libraries into one Web Components library. Here's the architecture that worked:

design-system/
├── src/
│   ├── components/
│   │   ├── button/
│   │   │   ├── button.ts          # Web Component implementation
│   │   │   ├── button.styles.ts   # Shared styles
│   │   │   └── button.test.ts     # Framework-agnostic tests
│   │   ├── modal/
│   │   └── ...
│   ├── utils/
│   │   ├── define-element.ts      # Registration helper
│   │   └── event-emitter.ts       # Custom event utilities
│   └── index.ts                   # Main export
├── react/
│   └── types.d.ts                 # React JSX types
├── vue/
│   └── types.d.ts                 # Vue component types
└── angular/
    └── types.d.ts                 # Angular module types

The core components are framework-agnostic Web Components. Each framework gets only type definitions - no runtime code. Components register themselves when imported:

// design-system/src/utils/define-element.ts
export function defineElement(name: string, constructor: CustomElementConstructor) {
  if (!customElements.get(name)) {
    customElements.define(name, constructor);
  }
}

Performance Comparison

After migrating to Web Components, we measured the impact:

Bundle Size: The shared Web Components library is 45KB gzipped. Previously, each framework-specific library was 60-80KB. Total savings across three frameworks: ~150KB.

Development Time: Component updates now require one PR instead of three. Average feature delivery time dropped from 2.5 days to 0.8 days.

Consistency: Visual regression tests caught zero cross-framework inconsistencies after migration (previously 2-3 per release).

Runtime Performance: Shadow DOM encapsulation adds minimal overhead. Our benchmarks showed < 5ms difference in mount time for component trees of 100+ elements.

Limitations to Know

React-to-Web-Component is one-way: React 19 consumes Web Components excellently, but React components don't automatically export as Web Components. If you need React components in non-React apps, you still need tools like @lit-labs/react or custom wrappers.

Global Registration: customElements.define() is global. Name collisions can occur if multiple versions of a component load. We use versioned names in development (notification-badge-v2) and strict version control in production.

Shadow DOM Styling: CSS from parent components can't penetrate Shadow DOM by default. Use CSS custom properties (CSS variables) for theming:

// In Web Component
this.shadow.innerHTML = `
  <style>
    .badge {
      background: var(--badge-bg, #6b7280);
      color: var(--badge-color, white);
    }
  </style>
  <span class="badge">${this.count}</span>
`;
/* In parent application */
:root {
  --badge-bg: #3b82f6;
  --badge-color: white;
}

Form Association: Web Components require explicit form association for form elements. Use ElementInternals API for form-participating custom elements:

class CustomInput extends HTMLElement {
  static formAssociated = true;
  private internals: ElementInternals;
 
  constructor() {
    super();
    this.internals = this.attachInternals();
  }
 
  set value(val: string) {
    this.internals.setFormValue(val);
  }
}

When Web Components Make Sense

Based on our experience, Web Components provide the most value when:

  1. Multiple frameworks coexist: Micro-frontends, gradual migrations, or teams with different framework preferences benefit most.

  2. Design system distribution: Enterprise design systems serving React, Vue, Angular, and vanilla JS teams.

  3. Third-party widget development: Embeddable components that work regardless of consumer's tech stack.

  4. Long-term stability: Standards-based components survive framework version changes better than framework-specific code.

For single-framework applications with no cross-framework requirements, native React components still offer the best developer experience and ecosystem integration.

Migration Path from Wrapper Libraries

If you're using Stencil, FAST, or Lit with React wrappers, React 19 lets you remove the wrapper layer:

// Before: With Stencil React wrapper
import { MyButton } from '@my-lib/react';
 
// After: Direct Web Component usage
import '@my-lib/components';
 
function App() {
  return <my-button variant="primary">Click me</my-button>;
}

Remove the framework-specific packages from your dependencies. The core Web Components package is all you need. Update your type definitions to use JSX intrinsic elements instead of wrapper component types.

React 19's native Web Components support represents a significant step toward framework-agnostic component development. For teams managing cross-framework design systems or building embeddable widgets, this eliminates substantial complexity. The 100% Custom Elements Everywhere score means Web Components finally work in React the way they work everywhere else.