Skip to main content

Using with React

There are two ways to use the components in React:

  • Plain tags. React 19 supports custom elements directly. Write <rc-select> in JSX and reference the generated JSX typings.
  • Wrappers. @rcarls/rc-react provides one React component per element. Each wrapper registers its element, takes typed props and on event props in camel case, and also works in React 18.

Both keep the native child the component enhances, so forms, labels, and pre-upgrade behavior work the same either way.

Plain tags (React 19)​

Register the elements you use once in your application entry point. Prefer the aggregate package's per-component subpaths so unused components stay out of the bundle:

main.tsx
import '@rcarls/rc-webcomponents/rc-dialog/define';
import '@rcarls/rc-webcomponents/rc-select/define';
import { createRoot } from 'react-dom/client';
import { App } from './App';

createRoot(document.getElementById('root')!).render(<App />);

Add the JSX typings once, for example in vite-env.d.ts:

vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="@rcarls/rc-webcomponents/react" />

The typings are generated from each component's source. They cover every attribute and property, type each element's custom events, and accept the components' CSS custom properties in style objects.

Attributes and properties​

React 19 sets a prop as a property when the element has one by that name, and as an attribute otherwise. Use the kebab-case attribute for anything that should appear in server-rendered markup, and the camel-case property for rich values such as arrays:

<rc-select popup-mode="dialog" value={['draft']}>
<select name="status" aria-label="Status" multiple>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</rc-select>

Events​

React 19 attaches an on<event-name> prop as a listener for exactly that event, so custom events need no useEffect. The handler receives the typed event:

<rc-select onrc-select-change={(event) => console.log(event.detail.value)}>
<select name="status" aria-label="Status">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</rc-select>

Each component's page documents its events and the shape of event.detail.

Refs and methods​

Type refs with the element's class, imported from its package or the aggregate subpath:

import { useRef } from 'react';
import type { RCDialog } from '@rcarls/rc-webcomponents/rc-dialog';

function ConfirmDialog() {
const ref = useRef<RCDialog>(null);

return (
<>
<button type="button" onClick={() => ref.current?.showModal()}>
Open
</button>
<rc-dialog ref={ref} movable resize="both">
<dialog aria-labelledby="confirm-title">
<strong id="confirm-title">Confirm</strong>
<button type="button" onClick={() => ref.current?.close('ok')}>
OK
</button>
</dialog>
</rc-dialog>
</>
);
}

Wrappers (@rcarls/rc-react)​

yarn add @rcarls/rc-react @rcarls/rc-webcomponents lit

Import each wrapper from the subpath named after its component package. The module registers the element when it loads:

'use client';

import { RcSelect } from '@rcarls/rc-react/rc-select';

export function StatusPicker() {
return (
<RcSelect popup-mode="dialog" onRcSelectChange={(event) => console.log(event.detail.value)}>
<select name="status" aria-label="Status">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</RcSelect>
);
}

Wrapper refs resolve to the element instance, typed as its class. Event props are the event name in camel case: rc-select-change becomes onRcSelectChange.

Server rendering and Next.js​

Every package imports safely on the server, so client components that use the wrappers or import a define entry render in the Next.js App Router without dynamic imports. During a server render:

  • A wrapper outputs its tag, its attributes, and its children. Props that are element properties are set in the browser after hydration, so pass the attribute form for state the server markup needs.
  • Plain tags from a React Server Component render as written and upgrade once their definitions load. Render RcRegister once in the root layout with every such tag:
app/layout.tsx
import { RcRegister } from '@rcarls/rc-react';
import './globals.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<RcRegister tags={['rc-app-bar', 'rc-card', 'rc-chip']} />
</body>
</html>
);
}

RcRegister loads each definition as its own chunk after hydration, so React matches the server markup before any element upgrades and adds its own attributes.

To keep the native markup laid out before upgrade, load the static base and pre-upgrade stylesheets. With Tailwind CSS, also import @rcarls/rc-theme-tailwind/layers.css first so Preflight cannot override component structure. The server rendering guide covers both.

Declarative shadow DOM with React​

By default the server sends each element's light DOM only, and the element renders its shadow DOM once its definition loads. To send the shadow DOM in the server HTML as well, use @lit-labs/ssr-react. This works with the @rcarls/rc-react wrappers and with plain tags, and was verified with Next.js 16 and Turbopack:

  • Import @lit-labs/ssr-react/enable-lit-ssr.js first in a client component that renders the elements, and import the element definitions there too. On the server it patches React's element creation to add each element's declarative shadow root; in the browser it loads Lit's hydration support.
  • Render the elements from client components only. A server component's output reaches the browser twice, as HTML and as React's server component payload, and the payload still holds the <template> the browser has already turned into a shadow root. React then reports a hydration error and renders the whole tree again.
  • Leave jsxImportSource at its default. Setting it to @lit-labs/ssr-react as well as importing the patch renders each shadow root twice, and the package's JSX runtime declares no JSX types.
  • @lit-labs/ssr-react 0.3.4 joins the server output with join('') in development mode, which writes Lit's lazy render functions into the HTML as text. Until it is fixed upstream, patch lib/node/wrap-jsx.js to use collectResultSync(shadowContents), as its production path does.
  • Put event handlers such as onClick on the native child. On a wrapper, the server writes the function's source into an onclick attribute.
  • React's development build warns that attributes such as orientation or has-title differ from the server HTML: the elements write them on their hosts. Add suppressHydrationWarning to an element to silence it; production is unaffected.

React Router​

Declarative shadow DOM also works in React Router's framework mode, verified with React Router 8.5 and Vite 8. React Router has no server components by default, so every route module renders on the server and hydrates in the browser. The setup differs from Next.js in three places:

  • Bundle React into the server build. React Router's server build imports React from node_modules, and Node gives a module that names an import from a CommonJS package, such as jsx from react/jsx-runtime, the value it had when the package loaded. The patch from enable-lit-ssr.js replaces that function afterward, so plain tags render without shadow roots; wrappers still get them, because they call React.createElement on React's default export. Bundling every dependency into the production server build makes the patch apply to all of them. Bundle all of them rather than a list: a package left out, such as @lit/react, loads a second copy of React.
  • Register the elements on the server in entry.server.tsx, after the patch, and in the browser with RcRegister. A define import in a route module also runs in the browser before React hydrates, and React's development build then reports the attributes the elements wrote on their native children, such as role="switch" or a page link's aria-label.
  • Load hydration support from the root module. In the browser, enable-lit-ssr.js loads Lit's hydration support, which must run before any element definition.
vite.config.ts
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';

export default defineConfig(({ command }) => ({
// Bundle React with the server build so the Lit patch reaches the JSX runtime.
ssr: command === 'build' ? { noExternal: true } : {},
plugins: [reactRouter()],
}));
app/entry.server.tsx
import '@lit-labs/ssr-react/enable-lit-ssr.js';
import '@rcarls/rc-webcomponents/rc-chip/define';
import '@rcarls/rc-webcomponents/rc-switch/define';

// ...the rest of the default entry (`npx react-router reveal entry.server`)
app/root.tsx
import '@lit-labs/ssr-react/enable-lit-ssr.js';
import { RcRegister } from '@rcarls/rc-react';
import { Links, Meta, Scripts, ScrollRestoration } from 'react-router';

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
{children}
<RcRegister tags={['rc-chip', 'rc-switch']} />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

The development server keeps its default externals, because it cannot run React's CommonJS modules once they are bundled. In development, wrappers still render their shadow roots on the server and plain tags render theirs in the browser, so the page is the same after hydration. The @lit-labs/ssr-react 0.3.4 development-mode bug described above does not appear, since the development server never reaches the patched JSX runtime.