DEV Community

A0mineTV
A0mineTV

Posted on

Build Web Components with React using R2WC (react-to-web-component)

Web Components are great for distribution: ship one <my-widget> tag and it works in plain HTML, Laravel Blade, Rails, WordPress, or inside another SPA.

React is great for building UIs quickly.

So… why not build your UI in React and ship it as a Web Component?

That’s exactly what R2WC (@r2wc/react-to-web-component) does: it wraps a React component and registers it with customElements.define(...).

In this post, we’ll build two real components:

  • <r2wc-counter> — a typed counter widget (string/number/boolean/json props)
  • <confirm-dialog> — a confirmation modal that emits native DOM events (confirm / cancel)

Why React → Web Components ?

You get:

  • Framework-agnostic reuse: use the same widget in React, Vue, Blade, Astro, etc.
  • Clean integration: Web Components speak “DOM” (attributes, properties, events)
  • Controlled surface: expose a small prop/event API instead of shipping a whole app

Tradeoff: you still ship React inside the bundle. It’s not “React-free” — it’s “React encapsulated”.


Setup (Vite + React + TypeScript)

npm create vite@latest r2wc-demo -- --template react-ts
cd r2wc-demo
npm i
npm i @r2wc/react-to-web-component
npm run dev
Enter fullscreen mode Exit fullscreen mode

Your goal is simple:

  1. Build React components
  2. Convert them into Web Components
  3. Use them from index.html (no React needed)

Example 1 — <r2wc-counter> (props: string/number/boolean/json)

1) The React component

Create src/Counter.tsx:

import React, { useMemo, useState } from "react";

export type CounterConfig = {
  step?: number;
  max?: number;
};

export type CounterProps = {
  label: string;                // string
  start: number;                // number
  disabled: boolean;            // boolean
  config?: CounterConfig;       // json (passed as JSON string in HTML)
  onChange?: (value: number) => void; // function (passed via JS property)
};

export function Counter({
  label,
  start,
  disabled,
  config,
  onChange,
}: CounterProps) {
  const step = config?.step ?? 1;
  const max = config?.max ?? 999;

  const [count, setCount] = useState(start);

  const canInc = useMemo(
    () => !disabled && count + step <= max,
    [disabled, count, step, max]
  );

  function inc() {
    if (!canInc) return;
    const next = count + step;
    setCount(next);
    onChange?.(next);
  }

  return (
    <div
      style={{
        fontFamily: "system-ui",
        padding: 12,
        border: "1px solid #ddd",
        borderRadius: 12,
      }}
    >
      <div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
      <div style={{ fontSize: 28, margin: "8px 0" }}>{count}</div>

      <button onClick={inc} disabled={!canInc}>
        +{step}
      </button>

      {disabled && (
        <span style={{ marginLeft: 8, opacity: 0.7 }}>(disabled)</span>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

2) Convert it to a Web Component

Create src/web-components.ts:

import r2wc from "@r2wc/react-to-web-component";
import { Counter } from "./Counter";

customElements.define(
  "r2wc-counter",
  r2wc(Counter, {
    props: {
      label: "string",
      start: "number",
      disabled: "boolean",
      config: "json",
      onChange: "function",
    },
    shadow: "open", // optional but nice for encapsulation
  })
);
Enter fullscreen mode Exit fullscreen mode

3) Register it from the entry point

Replace src/main.tsx with:

import "./web-components";
Enter fullscreen mode Exit fullscreen mode

4) Use it in index.html (no React)

Update index.html (Vite root):

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>R2WC demo</title>
  </head>

  <body>
    <h1>R2WC Counter (vanilla HTML)</h1>

    <r2wc-counter
      id="counter"
      label="Downloads"
      start="3"
      disabled="false"
      config='{"step":2,"max":10}'
    ></r2wc-counter>

    <!-- Loads the file that registers custom elements -->
    <script type="module" src="/src/main.tsx"></script>

    <script type="module">
      const el = document.getElementById("counter");

      // Pass functions via JS properties (not HTML attributes)
      el.onChange = (value) => {
        console.log("[r2wc-counter] new value:", value);
      };
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

✅ You’ve built a React component and used it as a native HTML tag.


Example 2 — <confirm-dialog> (native DOM events)

This one is very handy for integration: a confirmation modal that dispatches real DOM events.

1) The React component

Create src/ConfirmDialog.tsx:

import React from "react";

type ConfirmDialogProps = {
  open: boolean;
  title: string;
  message: string;

  // R2WC will provide these via `events`
  onConfirm?: (payload: { at: string }) => void;
  onCancel?: (payload: { at: string }) => void;
};

export function ConfirmDialog({
  open,
  title,
  message,
  onConfirm,
  onCancel,
}: ConfirmDialogProps) {
  if (!open) return null;

  return (
    <div
      style={{
        position: "fixed",
        inset: 0,
        display: "grid",
        placeItems: "center",
        background: "rgba(0,0,0,0.45)",
        zIndex: 9999,
      }}
    >
      <div
        style={{
          width: 420,
          background: "white",
          borderRadius: 12,
          padding: 16,
        }}
      >
        <h3 style={{ margin: "0 0 8px" }}>{title}</h3>
        <p style={{ margin: "0 0 16px" }}>{message}</p>

        <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
          <button
            type="button"
            onClick={() => onCancel?.({ at: new Date().toISOString() })}
          >
            Cancel
          </button>

          <button
            type="button"
            onClick={() => onConfirm?.({ at: new Date().toISOString() })}
          >
            Confirm
          </button>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

2) Wrap it with R2WC + declare events

Create src/confirm-dialog.wc.ts:

import r2wc from "@r2wc/react-to-web-component";
import { ConfirmDialog } from "./ConfirmDialog";

const ConfirmDialogElement = r2wc(ConfirmDialog, {
  props: {
    open: "boolean",
    title: "string",
    message: "string",
  },

  // When React calls onConfirm(payload),
  // R2WC dispatches: new CustomEvent("confirm", { detail: payload, ... })
  events: {
    onConfirm: { bubbles: true, composed: true },
    onCancel: { bubbles: true, composed: true },
  },

  shadow: "open",
});

customElements.define("confirm-dialog", ConfirmDialogElement);
Enter fullscreen mode Exit fullscreen mode

3) Register both components

Update src/web-components.ts to include both:

import r2wc from "@r2wc/react-to-web-component";
import { Counter } from "./Counter";
import { ConfirmDialog } from "./ConfirmDialog";

customElements.define(
  "r2wc-counter",
  r2wc(Counter, {
    props: {
      label: "string",
      start: "number",
      disabled: "boolean",
      config: "json",
      onChange: "function",
    },
    shadow: "open",
  })
);

customElements.define(
  "confirm-dialog",
  r2wc(ConfirmDialog, {
    props: {
      open: "boolean",
      title: "string",
      message: "string",
    },
    events: {
      onConfirm: { bubbles: true, composed: true },
      onCancel: { bubbles: true, composed: true },
    },
    shadow: "open",
  })
);
Enter fullscreen mode Exit fullscreen mode

4) Use it in index.html + listen to native events

Add this to your index.html:

<button id="open">Open dialog</button>

<confirm-dialog
  id="dlg"
  title="Delete item?"
  message="This action cannot be undone."
></confirm-dialog>

<script type="module">
  const dlg = document.getElementById("dlg");
  const openBtn = document.getElementById("open");

  // Prefer JS properties for booleans
  dlg.open = false;

  openBtn.addEventListener("click", () => {
    dlg.open = true;
  });

  dlg.addEventListener("confirm", (e) => {
    console.log("CONFIRM payload:", e.detail);
    dlg.open = false;
  });

  dlg.addEventListener("cancel", (e) => {
    console.log("CANCEL payload:", e.detail);
    dlg.open = false;
  });
</script>
Enter fullscreen mode Exit fullscreen mode

✅ Now your React modal integrates like a native element with native DOM events.


Common gotchas (save yourself the pain)

1) Boolean attributes are tricky

In HTML, open="false" is still a present attribute and can behave like true.

Best practice: set booleans via JS properties:

dlg.open = true;
dlg.open = false;
Enter fullscreen mode Exit fullscreen mode

Or manipulate attributes explicitly:

dlg.setAttribute("open", "");
dlg.removeAttribute("open");
Enter fullscreen mode Exit fullscreen mode

2) Pass functions and complex objects via JS properties

  • function props: JS property only
  • json props: okay as an attribute, but keep it simple (or set via property)

3) Shadow DOM affects styling

Shadow DOM is great for encapsulation, but global CSS won’t reach inside.
If you need theming, consider:

  • CSS variables
  • explicit “theme” props
  • or disable shadow DOM (shadow: false) and style globally

When this approach shines

Use React → Web Components when you need:

  • a widget embedded in multiple stacks
  • a shared UI component you can ship once
  • a clean boundary (props/events) between host app and UI

Avoid it when:

  • you need SSR/hydration from the host framework
  • bundle size is extremely constrained
  • you need deep integration with host router/state

Conclusion

With R2WC, you can:

  • design components in React (fast iteration)
  • ship them as Web Components (portable + framework-agnostic)
  • integrate with typed props and native DOM events

Top comments (0)