nyrtzi.net

Reactive Custom Components

Last Updated: Tue Jun 16 21:35:20 EEST 2026

Topic: combining ArrowJS (reactivity) with Web Components (custom HTML tags).
Let’s go through the basics here using simple examples.
Instead spelling everything out I’ll try to keep it minimal
so that the important bits won’t get lost in the noise.

But to address the elephant in the room: why would you want to do this?
Because reactivity helps avoid manual DOM manipulation.
What is wrong with manual DOM manipulation?
It’s verbose and error-prone.
It doesn’t scale well as the codebase grows in size and complexity.

Just like it is when everything else, reactivity is not free.
It is a trade-off where you add the overhead and complexity of the reactive
system in exchange for the benefits it provides.

To me it seems like a pretty good deal. Personally I already got tired of integrating Svelte into existing codebases and so on.

Counterpoint: You could just use a framework like React, Vue, or Svelte?

But then you’d need to bring in a whole toolchain and a compilation process. ArrowJS can be used just by including it. That’s way less in terms of overhead and learning curve. You can just drop it in even to a vanilla JS project and start using it pretty much right away.

Hello, World! Custom Element

Does nothing but show the text where used.

hello-world.js

class HelloWorldComponent extends HTMLElement {
    constructor() {
        super();

        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `<span>Hello, world!</span>`;
    }
}

customElements.define('hello-world', HelloWorldComponent);

To summarize what this code does:

  1. Extends HTMLElement and therefore must invoke super() as the first thing in the constructor
  2. Creates and attaches a shadow DOM
  3. Defines the inner HTML of the shadow DOM
  4. Registers the custom element with a tag name

??.html

<script type="module" src="hello-world.js"></script>
<hello-world></hello-world>
  1. Regular ES6 import
  2. Use the custom element like a regular HTML tag

Live demo:

Counter Custom Element

custom-counter.js

class CustomCounter extends HTMLElement {
    constructor() {
        super();

        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <button id="increment"></button>
        `;

        this.counter = 0;

        const elm = this.shadowRoot.getElementById('increment');

        const updateButton = () => elm.textContent = `${this.counter}`;

        elm.addEventListener('click', () => {
            this.counter++;
            updateButton();
        });

        updateButton();
    }
}

customElements.define('custom-counter', CustomCounter);

On top of what the previous example does:

  1. Defines and initializes a counter variable
  2. Gets a reference to the button element
  3. Defines a function to update the button text with the current counter value
  4. Adds a click event listener to the button that increments the counter and updates the button text

??.html

<script type="module" src="custom-counter.js"></script>
<custom-counter></custom-counter>

Like in the previous example.

Live demo:

Counter with ArrowJS

A simplified version of the example from the ArrowJS website.

arrow-counter.js

import { component, reactive, html } from 'https://esm.sh/@arrow-js/core';

const ArrowCounter = component((props) => {
    const counter = reactive({ clicks: 0 })

    return html`<button @click="${() => counter.clicks++}">
        ${() => counter.clicks}
    </button>`;
});

export { ArrowCounter };

Breakdown:

  1. Necessary imports
  2. It’s best to accept the props as matter of convention
  3. Define and initialize the reactive counter
  4. Return the HTML template with the button and the click event listener

??.html

<div id="app"></div>
<script type="module">
    import { html } from 'https://esm.sh/@arrow-js/core';
    import { ArrowCounter } from './arrow-counter.js';
    html`${ArrowCounter()}`(document.getElementById('app'));
</script>

ArrowJS has a funky way of mounting components as you can see. Note that the import of the local component needs to have a relative path and the .js extension.

Live demo:

Intermediate Observations

The ArrowJS component is way more concise and easier to read.

But the custom element is easier to mount.

So the question is: can we combine these to have the best of both worlds?

Counter Custom Element with ArrowJS

hybrid-counter.js

import { component, html, reactive } from 'https://esm.sh/@arrow-js/core';

class HybridCounter extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.counter = reactive({ clicks: 0 });
    }

    connectedCallback() {
        html`
            <button @click="${() => this.counter.clicks++}">
                ${() => this.counter.clicks}
            </button>
        `(this.shadowRoot);
    }
}

customElements.define('hybrid-counter', HybridCounter);

We use the reactive html for the shadow DOM and the click event listener.

??.html

<script type="module" src="./hybrid-counter.js"></script>
<hybrid-counter></hybrid-counter>

Mounting that can be done without code.

Live demo:

Methods

with-methods.js

import { component, html, reactive } from 'https://esm.sh/@arrow-js/core';

class WithMethods extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.counter = reactive({ clicks: 0 });
    }

    connectedCallback() {
        html`<button>${() => this.counter.clicks}</button>`(this.shadowRoot);
    }

    increment() {
        this.counter.clicks++;
    }
}

customElements.define('with-methods', WithMethods);

Defines a method that can be called from the outside.

??.html

<script type="module" src="./with-methods.js"></script>
<with-methods></with-methods>
<script>
    const counter = document.querySelector('with-methods');
    counter.onclick = () => counter.increment();
</script>

Calls the method from the outside.

This use of the object’s explicitly declared interface is preferable for the usual reasons of modularity compared to outside code reaching out into the implementation details of the component which would make the code outside dependant on implementation details and therefore more brittle and harder to understand and maintain.

Why more brittle? Because it’s easier to track if the interface is being used correctly than to look in the codebase for all the places where the internal state is being manipulated and make sure it’s being done correctly everywhere.

Live demo:

Slots

slot-component.js

class SlotComponent extends HTMLElement {
    constructor() {
        super();

        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <h3><slot name="title"></slot></h3>
            <span><slot></slot></span>
        `;
    }
}

customElements.define('slot-component', SlotComponent);

Puts a title and a default slot in the shadow DOM.

??.html

<script type="module" src="slot-component.js"></script>
<slot-component>
    <span>for default slot</span>
    <span slot="title">for title slot</span>
</slot-component>

Puts content without a slot name in the default slot and content with the slot name “title” in the title slot.

Live demo:

for default slot for title slot

Attribute Changes

toggle-component.js

import { component, html, reactive } from 'https://esm.sh/@arrow-js/core';

class ToggleComponent extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.state = reactive({
            active: this.hasAttribute("active")
        });
    }

    static get observedAttributes() {
        return ["active"];
    }

    attributeChangedCallback(name) {
        if (name === "active") this.state.active = this.hasAttribute("active");
    }

    connectedCallback() {
         html`
            <style>
            :host { color: hotpink; }
            :host([active]) { color: black; }
            * {
                color: inherit;
            }
            </style>
            <button>
                ${() => (this.state.active ? "o" : "x")}
            </button>
        `(this.shadowRoot);
    }
}

customElements.define('toggle-component', ToggleComponent);

Registers the “active” attribute as an observed attribute and updates the reactive state when it changes. We can work with both the shadom DOM styles and the shallow native DOM using with the help of the :host selector.

??.html

<div class="demo">
    <toggle-component></toggle-component>
</div>
<script type="module" src="toggle-component.js"></script>
<script type="module">
    let i = document.querySelector("toggle-component");
    i.onclick = () => i.hasAttribute("active") ?
        i.removeAttribute("active") :
        i.setAttribute("active", "");
</script>

Makes the element clickable so that it toggles the “active” attribute on and off when clicked.

Live demo: