React and Custom Elements

Custom elements are the browser's own component model: a class extending HTMLElement, registered under a hyphenated tag name with customElements.define, usable anywhere HTML is. React 19 7,897 added full support for them and passes every test on Custom Elements Everywhere, which earlier versions did not.

React passing a property and an attribute to a custom elementHTML
<div id="root"></div><style>body{font:14px system-ui;margin:6px}</style>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script>
  class StarRating extends HTMLElement {              // plain DOM, no React involved
    set score(v) { this.textContent = '*'.repeat(v.stars) + ` (${v.label})`; }
  }
  customElements.define('star-rating', StarRating);
</script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",
  "react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>
<script type="text/babel" data-type="module">
  import React from 'react';
  import { createRoot } from 'react-dom/client';
  const App = () => <p>Rating:{' '}
    <star-rating score={{ stars: 4, label: 'good' }} variant="compact" /></p>;
  createRoot(document.getElementById('root')).render(<App />);
  setTimeout(() => {
    const el = document.querySelector('star-rating');
    document.body.insertAdjacentHTML('beforeend',
      `<p>score as attribute? ${el.hasAttribute('score')} —
       variant as attribute? ${el.hasAttribute('variant')}</p>`);
  }, 100);
</script>
Browser output of Listing 2.95
Browser output of 95

The client rule is simple: if the prop name matches a property that exists on the element instance, React assigns it as a property; otherwise it sets an attribute. That is why score — an object, which could never survive being stringified into an attribute — arrived intact at the setter, while variant became an attribute. During server rendering there are no instances to inspect, so primitive props (string, number, true) render as attributes and everything else (object, function, symbol, false) is omitted.

Two rough edges remain. React has no onSomething mapping for custom events, so listen with a ref and addEventListener in an effect and remove it in the cleanup. And registration must happen before React renders the tag, or the browser treats it as an unknown element and upgrades it later — import the definition module at the top of your entry file.