Skip to content

Migrate from Stimulus to Webuum

Stimulus and Webuum both progressively enhance server-rendered HTML, but they organize behavior differently. Stimulus attaches controller instances to existing elements through an application runtime. Webuum uses native Custom Elements, browser lifecycle callbacks, DOM events and a small set of helpers for commands, parts and props.

This is therefore not a dependency-only upgrade. The goal is to move each controller's behavior to the platform or to a focused Custom Element, then remove the Stimulus application and dependency completely.

Webuum is currently pre-release

This guide targets Webuum 0.2.x. Review Webuum's changelog before upgrading later pre-release versions because APIs may still change before the stable release.

Decide what should replace each controller

Do not recreate every Stimulus controller automatically. Audit what the controller does first:

Existing controller responsibilityRecommended replacement
Opens a dialog or popover, toggles details, or duplicates another browser APIUse the native HTML API and remove the controller
Owns local behavior for a reusable piece of markupCreate an autonomous or customized built-in Custom Element
Only coordinates a parent with its descendantsUse one Custom Element with Webuum parts and delegated listeners
Invokes an action on another componentUse native command and commandfor attributes
Notifies a parent or unrelated application codeDispatch a bubbling CustomEvent
Manages application-wide state, client routing or a complex SPAKeep the appropriate application architecture; Webuum is not a state-management or SPA framework

The migration should reduce abstraction where the platform already provides the behavior. Avoid building a new global controller registry on top of Webuum.

1. Install Webuum alongside Stimulus

Keep Stimulus installed while migrating incrementally:

shell
npm install webuum

Define the first migrated element in an application entry point:

js
import { WebuumElement } from 'webuum'

class FilterElement extends WebuumElement {
    connectedCallback() {
        // Component setup
    }
}

customElements.define('x-filter', FilterElement)

Custom-element names must contain a hyphen. A definition runs once per name and automatically upgrades matching elements that already exist or are inserted later; there is no equivalent of Application.start().

Stimulus and Webuum can coexist during the migration. Remove the Stimulus controller from a piece of markup as soon as its Custom Element takes ownership, so the same interaction is never connected twice.

2. Choose the element root

Stimulus can attach a controller to any existing element. With Webuum, that element becomes the component boundary.

Autonomous Custom Elements

Use an autonomous Custom Element for a component without a native element that already expresses its semantics:

html
<div class="filter" data-controller="filter">
    <!-- component content -->
</div>
html
<x-filter class="filter">
    <!-- component content -->
</x-filter>

Autonomous elements extend WebuumElement and use a tag such as <x-filter>. They are inline by default, so preserve the old root's display, sizing and other layout styles explicitly when changing from a block-level element such as <div>.

Customized built-in elements

When the Stimulus controller enhances a native element such as <form>, <button> or <dialog>, keep that native element and define a customized built-in:

js
import { defineElement } from 'webuum'

class FormElement extends HTMLFormElement {
    constructor() {
        super()
        defineElement(this)
    }

    connectedCallback() {
        this.addEventListener('submit', this.handleSubmit, {
            signal: this.$signal,
        })
    }

    disconnectedCallback() {
        this.$controller?.abort()
    }

    handleSubmit(event) {
        // Enhance the native form submission
    }
}

customElements.define('x-form', FormElement, { extends: 'form' })
html
<form is="x-form">
    <!-- native form content -->
</form>

The is attribute and matching { extends: 'form' } option are both required. Customized built-ins need an opt-in fallback in Safari; add it after the component migration is working as described in Polyfills.

3. Replace Controller and lifecycle APIs

The common Stimulus APIs map to the Custom Element itself:

StimulusWebuum / Custom Elements
extends Controllerextends WebuumElement, or extend a native element and call defineElement(this)
this.elementthis
initialize()constructor() after super(); do not depend on child markup here
connect()connectedCallback()
disconnect()disconnectedCallback()
this.identifierUsually unnecessary; use this.localName or the is attribute when needed
this.applicationNo global equivalent; use native DOM relationships, commands and events

Use Webuum's lifecycle-bound $signal instead of manually pairing every listener with teardown code:

js
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
    connect() {
        this.scroll = () => this.update()
        window.addEventListener('scroll', this.scroll)
    }

    disconnect() {
        window.removeEventListener('scroll', this.scroll)
    }

    update() {
        this.element.toggleAttribute('data-scrolled', window.scrollY > 0)
    }
}
js
import { WebuumElement } from 'webuum'

class ScrollSpyElement extends WebuumElement {
    connectedCallback() {
        window.addEventListener('scroll', () => this.update(), {
            signal: this.$signal,
        })

        this.update()
    }

    update() {
        this.toggleAttribute('data-scrolled', window.scrollY > 0)
    }
}

customElements.define('x-scroll-spy', ScrollSpyElement)

WebuumElement aborts $signal when it disconnects and creates a fresh signal when the same element reconnects. If a subclass overrides disconnectedCallback(), call super.disconnectedCallback(). A customized built-in does not inherit that cleanup, so abort this.$controller explicitly.

4. Replace targets with parts

Webuum parts provide scoped references to descendants and observe parts inserted or removed later.

StimulusWebuum
static targets = ['input']static parts = { $input: null }
data-filter-target="input"data-x-filter-part="input"
this.inputTargetthis.$input
this.hasInputTargetBoolean(this.$input)
this.inputTargetConnected(element)partConnectedCallback('$input', element)
this.inputTargetDisconnected(element)partDisconnectedCallback('$input', element)
js
export default class extends Controller {
    static targets = ['input', 'item']

    connect() {
        this.inputTarget.focus()
        console.log(this.itemTargets)
    }
}
js
class FilterElement extends WebuumElement {
    static parts = {
        $input: null,
        $item: null,
    }

    connectedCallback() {
        this.$input?.focus()

        const items = [this.$item].flat().filter(Boolean)
        console.log(items)
    }
}
html
<x-filter>
    <input data-x-filter-part="input">
    <div data-x-filter-part="item"></div>
    <div data-x-filter-part="item"></div>
</x-filter>

A Webuum part getter returns null when there is no match, the element when there is one match, and an array when there are multiple matches. Normalize it as shown above when application code always expects an array.

Use partConnectedCallback(name, element) and partDisconnectedCallback(name, element) when behavior must react to dynamic descendants. Prefer an event listener delegated from the Custom Element root when several parts emit the same bubbling event.

5. Replace values with props

Stimulus values include the controller identifier in their attributes. Webuum props are short data-* attributes on the Custom Element and are typecast from their contents.

js
export default class extends Controller {
    static values = {
        query: String,
        limit: { type: Number, default: 10 },
        enabled: { type: Boolean, default: false },
    }

    connect() {
        console.log(this.queryValue, this.limitValue, this.enabledValue)
    }
}
js
class FilterElement extends WebuumElement {
    static props = {
        $query: '',
        $limit: 10,
        $enabled: false,
    }

    connectedCallback() {
        console.log(this.$query, this.$limit, this.$enabled)
    }
}
html
<div
    data-controller="filter"
    data-filter-query-value="news"
    data-filter-limit-value="20"
    data-filter-enabled-value="true"
></div>
html
<x-filter
    data-query="news"
    data-limit="20"
    data-enabled="true"
></x-filter>

Webuum does not declare a prop type separately. Strings, booleans, numbers, arrays, objects and null are parsed from the attribute, while the value in static props is the default. Use explicit values such as data-enabled="true" or data-enabled="false" for booleans. Normalize a prop explicitly in component code when a value such as "123" must remain a string instead of being typecast to a number.

Writing this.$query = 'latest' updates data-query. There is no direct hasQueryValue property; use this.dataset.query !== undefined when the distinction between an absent attribute and its default matters.

Value change callbacks

Webuum props are DOM-backed getters and setters rather than a separate reactive system. Replace queryValueChanged() with the native observedAttributes and attributeChangedCallback() APIs when a component must react immediately to a changed prop:

js
class FilterElement extends WebuumElement {
    static props = {
        $query: '',
    }

    static observedAttributes = ['data-query']

    attributeChangedCallback(name, oldValue, newValue) {
        if (this.isConnected && name === 'data-query' && oldValue !== newValue) {
            this.update()
        }
    }
}

Do not add change callbacks when the prop is only read in response to another event or method call.

6. Replace actions with commands or event listeners

Stimulus action descriptors combine event registration, controller lookup and method invocation. In Webuum, choose the native mechanism based on the event:

Stimulus actionWebuum replacement
A button click invokes a component methodCustom Invoker Command such as command="--clear"
A button opens or closes a native dialog or popoverNative command such as show-modal, close or toggle-popover
input, change, keyboard, pointer or another eventaddEventListener() in connectedCallback() using $signal
window->resize or document->clickListener on that target using $signal
Action option or keyboard filterNative listener options and an explicit condition in the handler

Button actions

For a button inside the Custom Element, commandfor can be omitted. Webuum assigns the host as the command target:

html
<button
    data-action="filter#clear"
    data-filter-reset-param="true"
>
    Clear
</button>
html
<button command="--clear" value="true">Clear</button>
js
clear({ source }) {
    console.log(source.$value) // true
}

For an external button, give the Custom Element an ID and add commandfor:

html
<x-filter id="articleFilter"></x-filter>
<button command="--clear" commandfor="articleFilter">Clear</button>

A command's value is available as a typecast event.source.$value; the original string remains in event.source.value. For several action params, read named data-* attributes from event.source.dataset, or keep configuration as props on the target element.

Other events

Commands intentionally handle button actions only. Replace every other action with a native listener:

html
<input
    data-filter-target="input"
    data-action="input->filter#filter keydown.esc->filter#clear:prevent"
>
js
connectedCallback() {
    this.addEventListener('input', event => this.filter(event), {
        signal: this.$signal,
    })

    this.addEventListener('keydown', (event) => {
        if (event.key !== 'Escape') return

        event.preventDefault()
        this.clear()
    }, { signal: this.$signal })
}

Delegate bubbling events from the Custom Element root when possible. This keeps listeners working for descendants inserted later and avoids reconnecting the same listener for every matching part.

7. Replace dispatch, outlets and cross-controller calls

Webuum does not wrap the platform's event system. Replace this.dispatch() with a bubbling CustomEvent:

js
this.dispatch('changed', {
    detail: { query: this.queryValue },
})
js
this.dispatchEvent(new CustomEvent('filter:changed', {
    bubbles: true,
    detail: { query: this.$query },
}))

Choose the least coupled replacement for Stimulus outlets and getControllerForElementAndIdentifier():

  • Use a part when the other element is an owned descendant of the component.
  • Use commandfor when a button invokes a public method on another element.
  • Dispatch a bubbling custom event when a child reports a state change to a parent or application code.
  • Use an explicit DOM reference when two elements genuinely need a direct relationship.

Avoid reaching into another Custom Element's internal parts. Treat commands, native methods, props and custom events as its public API.

8. Replace Stimulus classes and controller state

Stimulus CSS classes do not have a dedicated Webuum equivalent. Use ordinary classList, ARIA state and data-* attributes. If a class name must remain configurable from HTML, expose it as a prop:

js
static classes = ['loading']

start() {
    this.element.classList.add(...this.loadingClasses)
}
html
<div
    data-controller="upload"
    data-upload-loading-class="opacity-50 pointer-events-none"
></div>
js
static props = {
    $loadingClass: 'is-loading',
}

start() {
    this.classList.add(...this.$loadingClass.split(' '))
}
html
<x-upload data-loading-class="opacity-50 pointer-events-none"></x-upload>

Keep private runtime state as fields on the Custom Element. Reflect state to an ARIA attribute, native property or data-* attribute when CSS, server-rendered markup or external code needs to observe it.

9. Complete example

This example combines lifecycle, targets, values and actions in one migration.

js
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
    static targets = ['input', 'item', 'empty']
    static values = {
        query: { type: String, default: '' },
    }

    connect() {
        this.update()
    }

    filter({ target }) {
        this.queryValue = target.value
        this.update()
    }

    clear() {
        this.inputTarget.value = ''
        this.queryValue = ''
        this.update()
    }

    update() {
        const query = this.queryValue.toLowerCase()
        let visible = 0

        this.itemTargets.forEach((item) => {
            const match = item.textContent.toLowerCase().includes(query)

            item.hidden = !match
            if (match) visible++
        })

        this.emptyTarget.hidden = visible > 0
    }
}
html
<div data-controller="filter" data-filter-query-value="">
    <input
        data-filter-target="input"
        data-action="input->filter#filter"
    >
    <button data-action="filter#clear">Clear</button>

    <ul>
        <li data-filter-target="item">News</li>
        <li data-filter-target="item">Guides</li>
    </ul>

    <p data-filter-target="empty" hidden>No results</p>
</div>
js
import { WebuumElement } from 'webuum'

export class FilterElement extends WebuumElement {
    static parts = {
        $input: null,
        $item: null,
        $empty: null,
    }

    static props = {
        $query: '',
    }

    connectedCallback() {
        this.addEventListener('input', ({ target }) => {
            if (target !== this.$input) return

            this.$query = target.value
            this.update()
        }, { signal: this.$signal })

        this.update()
    }

    clear() {
        this.$input.value = ''
        this.$query = ''
        this.update()
    }

    update() {
        const query = String(this.$query).toLowerCase()
        const items = [this.$item].flat().filter(Boolean)
        let visible = 0

        items.forEach((item) => {
            const match = item.textContent.toLowerCase().includes(query)

            item.hidden = !match
            if (match) visible++
        })

        if (this.$empty) this.$empty.hidden = visible > 0
    }
}

customElements.define('x-filter', FilterElement)
html
<x-filter data-query="">
    <input data-x-filter-part="input">
    <button command="--clear">Clear</button>

    <ul>
        <li data-x-filter-part="item">News</li>
        <li data-x-filter-part="item">Guides</li>
    </ul>

    <p data-x-filter-part="empty" hidden>No results</p>
</x-filter>

The migrated element owns the same local behavior without a controller application, action parser or manual reconnect cleanup.

10. Migrate incrementally and remove Stimulus

Use a controller-by-controller rollout:

  1. Inventory every registered controller and its templates, targets, values, actions, classes, outlets and external calls.
  2. Remove controllers whose behavior is now native HTML.
  3. Choose one local controller and define its Custom Element.
  4. Replace its root, targets, values and actions in every template.
  5. Remove that controller's registration and import in the same change.
  6. Test initial server-rendered markup, dynamically inserted markup, disconnect and reconnect behavior.
  7. Repeat until only application-specific controllers remain.
  8. Migrate those controllers using this guide, unless they are outside Webuum's website-focused scope.

Search for remaining Stimulus dependencies and descriptors:

text
@hotwired/stimulus
Application.start
application.register
data-controller
data-action
data-*-target
data-*-value
data-*-class
data-*-outlet
data-*-param

Once no application code or third-party integration requires Stimulus, remove its bootstrap and dependency:

shell
npm uninstall @hotwired/stimulus

Check the lockfile and production bundle to confirm that Stimulus is no longer included transitively.

Polyfills

Webuum uses native Invoker Commands, and customized built-ins need a fallback in Safari. Install only the fallbacks required by the project's browser support matrix:

shell
npm install invokers-polyfill @webreflection/custom-elements-builtin

You can load Webuum's conditional polyfill entry before defining elements:

js
import 'webuum/polyfill'

Or use the individual feature checks from the Polyfills documentation when the application needs more control.

Final checklist

  • Every migrated Custom Element is defined exactly once.
  • Customized built-ins keep their native element, is attribute and registration option.
  • Existing block-level roots keep the intended layout after becoming autonomous elements.
  • Button actions use commands; input, keyboard, pointer and global events use listeners with $signal.
  • Native dialog, popover and details behavior is not reimplemented unnecessarily.
  • Dynamic parts and server-inserted Custom Elements initialize correctly.
  • Overridden disconnectedCallback() methods preserve Webuum cleanup.
  • No component is initialized by both Stimulus and Webuum.
  • Stimulus imports, registration, descriptors and production bundle code are gone.

Released under the MIT License.