Logo

Web Components: Powerful, Yes. A Joy to Write? Not Quite (Coming from React)

Coming from years of React development, I find Web Components incredibly powerful and standards-based, but writing them can feel surprisingly tedious.

Published: 12 May, 2025


For years, React has been my go-to for building dynamic, interactive user interfaces. Its declarative nature, component model, and ecosystem are just fantastic. But the allure of Web Components – true encapsulation, framework-agnosticism, and the promise of “write once, run anywhere (in a browser)” – is strong. So, I’ve been rolling up my sleeves and diving in.

My verdict? Web Components are incredibly powerful. They deliver on their core promises. But, man, coming from React, the developer experience can feel like a significant step backward in terms of verbosity and boilerplate.

Let’s break it down with a simple example: a counter component.

The React Way: Sweet and Concise

In React, creating a simple counter is a breeze:

// SimpleCounter.jsx
import React, { useState } from "react";

function SimpleCounter({ initialValue = 0 }) {
  const [count, setCount] = useState(initialValue);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}

export default SimpleCounter;

// Usage:
// <SimpleCounter initialValue={5} />

What I love here:

  1. JSX: Declarative and intuitive HTML-like syntax.
  2. useState: Simple, effective state management.
  3. Props: Easy to pass data down (initialValue).
  4. Event Handling: Inline and straightforward (onClick).
  5. Minimal Boilerplate: It’s pretty much all business logic.

The Web Component Way: The Scenic Route

Now, let’s build the same counter as a “vanilla” Web Component:

// simple-counter.js
const template = document.createElement("template");
template.innerHTML = `
  <style>
    /* Basic styling, encapsulated! */
    :host {
      display: inline-block;
      border: 1px solid #ccc;
      padding: 10px;
      font-family: sans-serif;
    }
    button {
      margin: 0 5px;
      padding: 5px 10px;
    }
  </style>
  <div>
    <p>Count: <span id="count-value">0</span></p>
    <button id="increment-btn">Increment</button>
    <button id="decrement-btn">Decrement</button>
  </div>
`;

class SimpleCounter extends HTMLElement {
  constructor() {
    super(); // Always call super first in constructor
    this.attachShadow({ mode: "open" }); // Attach a shadow DOM
    this.shadowRoot.appendChild(template.content.cloneNode(true)); // Append the template

    // Get references to DOM elements
    this._countValueElement = this.shadowRoot.getElementById("count-value");
    this._incrementButton = this.shadowRoot.getElementById("increment-btn");
    this._decrementButton = this.shadowRoot.getElementById("decrement-btn");

    // Initialize state
    this._count = 0;
  }

  // Properties/Attributes
  static get observedAttributes() {
    return ["initial-value"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "initial-value" && oldValue !== newValue) {
      const initial = parseInt(newValue, 10);
      if (!isNaN(initial)) {
        this._count = initial;
        this._render();
      }
    }
  }

  connectedCallback() {
    // Set initial count if attribute was present before connection
    // or if it's set programmatically after constructor but before connection.
    if (this.hasAttribute("initial-value")) {
      const initial = parseInt(this.getAttribute("initial-value"), 10);
      if (!isNaN(initial)) {
        this._count = initial;
      }
    }
    this._render(); // Initial render

    // Add event listeners
    this._incrementButton.addEventListener("click", this._increment.bind(this));
    this._decrementButton.addEventListener("click", this._decrement.bind(this));
  }

  disconnectedCallback() {
    // Clean up event listeners
    this._incrementButton.removeEventListener(
      "click",
      this._increment.bind(this)
    );
    this._decrementButton.removeEventListener(
      "click",
      this._decrement.bind(this)
    );
  }

  // Internal methods
  _increment() {
    this._count++;
    this._render();
  }

  _decrement() {
    this._count--;
    this._render();
  }

  _render() {
    this._countValueElement.textContent = this._count;
  }

  // Optional: Programmatic getter/setter for count
  get count() {
    return this._count;
  }

  set count(value) {
    this._count = value;
    this._render();
  }
}

customElements.define("simple-counter", SimpleCounter);

// Usage:
// <simple-counter initial-value="5"></simple-counter>
// or
// const counter = document.createElement('simple-counter');
// counter.count = 10; // using programmatic setter
// document.body.appendChild(counter);

Phew! Let’s unpack the “tedium” points:

  1. Boilerplate Overload:
  • constructor with super().
  • attachShadow({ mode: 'open' }).
  • Cloning and appending a <template> element.
  • customElements.define(...).
  • Lifecycle callbacks (connectedCallback, disconnectedCallback, attributeChangedCallback).
  1. Imperative DOM Manipulation:
  • We need to manually query for elements (this.shadowRoot.getElementById(...)).
  • We need a manual _render() function to update the DOM when state changes (this._countValueElement.textContent = ...). React handles this declaratively via its virtual DOM and reconciliation.
  1. Attribute Handling:
  • observedAttributes static getter to tell the browser which attributes to watch.
  • attributeChangedCallback to react to changes. This is powerful for syncing attributes to internal state, but it’s verbose for simple props. Parsing and type conversion are manual.
  1. Templating:

Using document.createElement('template') and innerHTML is okay for small components. For larger ones, it gets unwieldy. String-based HTML lacks type checking and good editor support compared to JSX.

  1. Event Handling:

Manual addEventListener and removeEventListener (don’t forget to clean up in disconnectedCallback!). Binding this is a common gotcha.

6.State Management:

Entirely manual. We create a _count property and methods to update it, then manually trigger a re-render.

Why the Difference?

React is a library built on top of web standards, providing a higher-level abstraction layer. It’s designed for developer experience and efficiency in building complex UIs.

Web Components are platform primitives. They are lower-level. This is their strength (interoperability, no framework dependency) but also the source of their verbosity when used “vanilla.” You’re working much closer to the metal.

Is There Hope for a Better DX with Web Components?

Absolutely! This is where libraries built on top of Web Components shine. Tools like:

  • Lit (formerly LitElement/LitHTML): From Google, this is probably the closest you’ll get to a React-like DX with Web Components. It provides a simple base class, declarative templates (via tagged template literals), reactive properties, and more.
  • Stencil.js: A compiler that generates standard-compliant Web Components, but lets you write them using TypeScript, JSX, and a component model similar to modern frameworks.

These tools significantly reduce the boilerplate and bring back a lot of the declarative goodness we love from frameworks like React, while still outputting standard Web Components.

Here’s a glimpse of how our counter might look with Lit:

// simple-counter-lit.js
import { LitElement, html, css } from "lit";

class SimpleCounterLit extends LitElement {
  static styles = css`
    :host {
      display: inline-block;
      border: 1px solid #ccc;
      padding: 10px;
      font-family: sans-serif;
    }
    button {
      margin: 0 5px;
      padding: 5px 10px;
    }
  `;

  static properties = {
    count: { type: Number },
  };

  constructor() {
    super();
    this.count = 0; // Initialize property
  }

  // Lit handles attribute-to-property reflection for 'count' if you set it as <simple-counter-lit count="5">
  // Or, you can use a different attribute name for initialValue and map it in connectedCallback or firstUpdated
  // For simplicity, let's assume 'count' is set directly or via attribute.

  _increment() {
    this.count++;
  }

  _decrement() {
    this.count--;
  }

  render() {
    return html`
      <div>
        <p>Count: ${this.count}</p>
        <button @click=${this._increment}>Increment</button>
        <button @click=${this._decrement}>Decrement</button>
      </div>
    `;
  }
}

customElements.define("simple-counter-lit", SimpleCounterLit);

// Usage:
// <simple-counter-lit count="5"></simple-counter-lit>

Notice how Lit brings back declarative rendering (html tagged template literal), reactive properties (static properties), and simpler event handling (@click). It’s a huge improvement!

Final Thoughts

Web Components are a vital part of the web platform’s future. They offer true interoperability and encapsulation that no JavaScript framework alone can provide. However, if you’re coming from the plush comfort of React’s DX, writing vanilla Web Components can feel like a chore for anything beyond the simplest elements.

My current take:

  • For shareable, framework-agnostic UI primitives (design systems, etc.): Web Components are the way to go, but strongly consider using a library like Lit or Stencil to make your life easier.
  • For application development: If I’m already in a React ecosystem, I’ll stick to React components for application-specific UI. The DX and ecosystem benefits are too significant to ignore.

It’s not an either/or situation. React can consume Web Components, and Web Components can be used within React apps. But when it comes to the raw authoring experience, React spoiled us. And for that, I’m grateful, even as I appreciate the raw power (and occasional tedium) of the platform itself.


Email me if you have any questions about this post.

Subscribe to the RSS feed to keep updated.