DEV Community

Arsen Tamazyan
Arsen Tamazyan

Posted on

RWC: reactive web components

I rewrote my reactive library. Here's what changed

Last autumn I wrote about RWC (in Russian) — a library of reactive web components built on signals. No JSX, no template compiler: markup is assembled by typed factories div, button, input, and updates through signals, the way Solid does it.

Eight thousand people read that post and thirty left comments. I set out to smooth over a couple of rough edges and ended up rewriting the core.

Below is what changed, plus an answer to everyone who asked why any of this exists when Lit is right there. You don't need to read the first post.

What this looks like from the outside

The @component, @property and @event decorators are gone — 363 lines of magic that required experimentalDecorators and still didn't give decent type inference. One declaration replaces all of it:

class Counter extends defineComponent({
  props: { value: 0, step: 1 },
  events: { change: payload<number>() },
}) {
  render() {
    const { value, step } = this.props;
    return div(
      button({ "@click": () => value.update((v) => v - step()) }, ""),
      span(value),
      button({ "@click": () => value.update((v) => v + step()) }, "+"),
    );
  }
}

export const counter = useCustomComponent(Counter, "x-counter");
Enter fullscreen mode Exit fullscreen mode

In counter({ "@change": (e) => e.detail }) the type of detail is number, because that's what the declaration says. A typo in a prop name is a compile error, not an attribute that gets quietly ignored.

A function is now a reactive source everywhere a value can't itself be a function: span(() => count() * 2) works without computed. null, undefined and false are skipped among children, so isAdmin() && button("delete") does what you'd expect. There's a new resourcedata, loading, error, refetch, and cancellation of the previous request through AbortSignal. And identical CSS produces a single CSSStyleSheet per page via adoptedStyleSheets: a thousand buttons share one stylesheet instead of carrying a thousand <style> tags.

Now about the comments on the last post

The most common question was blunt: why bother, when Lit, Stencil and Solid exist?

Honest answer: if Lit fits your case, use Lit. It's more mature, it has far more users, and Google stands behind it. There are two differences. First, markup without template strings: in Lit that's html with interpolation — text checked at runtime; here markup is ordinary TypeScript, so go-to-definition, rename and find-references work with your editor's stock tooling, no plugin required. Second, @state in Lit re-renders the whole template, while a signal updates one specific text node.

The second objection was about maintenance: pick something more mature. Nothing to argue with there, it's true. All I can do is show what changed: tests, documentation in two languages, a check that inspects the built package the way a consumer sees it, and 2.x still around under a tag and the legacy/v2 branch rather than deleted. With the help of AI agents I plan to build out the rest of the ecosystem on this core quickly.

The third one was the most interesting, because it's largely fair. The argument: Web Components themselves are bad — host objects are slow, attributes are strings only, tag names are global, and shadow DOM gets in the way of styling.

The string-only attributes part is true and there's no cure: an attribute value goes through JSON.parse, and whatever doesn't parse stays a string. That's why the primary way to pass data here is DOM properties (.value), with attributes left for markup written by hand.

The global names part is true as well — and this is where something could actually be done. Normally the tag is nailed to the component for good: customElements.define("uwc-button", Button) somewhere deep inside the kit. A second call like that on the same page is an error.

configCustomComponent separates declaration from registration. The kit only declares, and hands out a factory/registrar pair:

// @company/ui-kit — customElements.define is not called here
class Button extends defineComponent({ props: { type: "secondary" } }) {
  render() {
    return button({ class: `btn btn-${this.props.type()}` }, slot());
  }
}

export const [UwcButton, registerUwcButton] = configCustomComponent(Button, "uwc-button");
Enter fullscreen mode Exit fullscreen mode

The application picks the tag name — once, at startup:

// the shell, running on kit 1.x
registerUwcButton({ postfix: "shell" }); // <uwc-button-shell>

// a widget on the same page, shipped with its own build of kit 2.x
registerUwcButton({ postfix: "widget" }); // <uwc-button-widget>
Enter fullscreen mode Exit fullscreen mode

Here's the part that matters. The application code in both places is identical, and it never mentions a tag name at all:

UwcButton({ ".type": "primary" }, "Buy");
Enter fullscreen mode Exit fullscreen mode

Yet the DOM ends up with two different elements, each carrying its own version of the kit:

<uwc-button-shell>Buy</uwc-button-shell>
<uwc-button-widget>Buy</uwc-button-widget>
Enter fullscreen mode Exit fullscreen mode

No renaming in markup, no flags, no build-time trickery — a single line at application startup is all that differs. One caveat: two versions on a page means two separate builds of the kit, which means two separate classes. A class lives under one tag, and trying to register it under a second name logs an error rather than producing a clone.

This scenario is exactly what the library was written for: a foundation for a kit that lives in several applications upgrading at different speeds.

I won't argue about styling and shadow DOM: it's a trade-off, and if you need themes that pierce through without CSS custom properties, it will hurt.

What I won't accept is the "nobody needs Web Components" line. Reddit moved from React to Lit in 2023, Edge is built on web components, and so are the Adobe and SAP design systems. This isn't "the future of frontend" — it's a working tool for one job: components that have to outlive a framework change in the app around them.

What it cost

There's no upgrade path from 2.x — the public API is different. Gone are the decorators, the router (it had no business being in a library like this), signal.pipe, forceSet, setName, getSubscribers; forkJoin and combineLatest are replaced by a single combine. A signal carries exactly three methods: set, peek, update.

The core shrank in the process: ~3100 lines down to 2250, with more features than before.

Wrapping up

The library: github.com/tamazyanarsen/reactive-web-components, installed with npm i @rwcjs/core. Inside you'll find a guide, an API reference and recipes in both English and Russian, plus an examples/ folder with working components.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.