Recreating Stimulus: how data-controller works under the hood

This is part 1 of a two-part series. Part 2, coming soon, will cover targets, actions and reactive values.

Yep, that text is programmatically added using a recreated Stimulus framework and a hello_world_controller.js

Stimulus is a modest JavaScript framework to sprinkle bits of interactivity. At its core it watches the DOM for elements with a data-controller attribute and connects them to JavaScript classes. When an element appears, a controller instance is created. When it disappears, the instance is cleaned up. That’s it. That’s the entire foundation.

I want to walk through how you can recreate Stimulus yourself to learn a few modern JavaScript features to have in your magic hat, you can tell your LLM agent to use instead.

To recreate it you need a few things: a registry to store controllers, a watcher for DOM changes and a base class for controllers to extend. Each of these maps to a modern JavaScript feature.

Here is the code. See the full commit on GitHub.

The registry

The Engine class holds a private map of registered controllers:

class Engine {
  #controllers = new Map()
  #connections = new WeakMap()
  #observer = null

  register(name, controllerClass) {
    this.#controllers.set(name, controllerClass)
  }
}

#controllers is a Map, not a plain object. Why? Maps have a get, set and has API. They accept any key type, not just strings. They are iterable with for...of. And they have a clear .size property. For a registry, Maps are more “honest” than objects.

The private # prefix is real ES2022 encapsulation (if you been around these parts of the internet, you have seen them many times before 🤓). No private access modifier like in Ruby. If you try to access engine.#controllers from outside the class, JavaScript throws an error (even when you inherit from the class with the private method; unlike with Ruby!).

Watching the DOM

Controllers need to connect when elements appear and disconnect when they disappear. The tool for that is MutationObserver:

start() {
  this.#observer = new MutationObserver(mutations => {
    for (const { addedNodes, removedNodes } of mutations) {
      for (const node of addedNodes) {
        if (node.nodeType === Node.ELEMENT_NODE) this.#wireUp(node)
      }
      for (const node of removedNodes) {
        if (node.nodeType === Node.ELEMENT_NODE) this.#wireDown(node)
      }
    }
  })

  this.#observer.observe(document.body, { childList: true, subtree: true })
  this.#wireUp(document.body)
}

MutationObserver fires a callback when the DOM changes. You pass it { childList: true, subtree: true } to observe all additions and removals anywhere in the body. When nodes come in, it scans for [data-controller] elements and connect them. When nodes leave, they are cleaned up.

The Node.ELEMENT_NODE check is a small detail that matters. MutationObserver also reports text nodes and comments. Those you do not want, so these are filtered.

The initial this.#wireUp(document.body) handles existing elements on page load. MutationObserver only fires on future changes.

Published by Rails Designer
Make JavaScript your second favorite language
Buy it today.

Tracking instances

When a controller is created, it needs to remember which element it belongs to. Later, when that element leaves the DOM, the instance is found and disconnect() is called.

A WeakMap is the right tool:

#wire(element) {
  if (this.#connections.has(element)) return

  const name = element.getAttribute("data-controller")
  const ControllerClass = this.#controllers.get(name)

  if (!ControllerClass) return

  const controller = new ControllerClass(element)
  this.#connections.set(element, controller)

  controller.connect()
}

#unwire(element) {
  const controller = this.#connections.get(element)
  if (!controller) return

  controller.disconnect()

  this.#connections.delete(element)
}

WeakMap uses the DOM element as its key. When the element is garbage collected, the entry disappears automatically. No manual cleanup needed. No risk of memory leaks.

A regular Map would hold a strong reference to the element and prevent garbage collection (GC; the automatic process of freeing up memory from unused objects). WeakMap does not hold such strong references, allowing objects to be garbage collected even if they’re stored in it. It is one of those JavaScript features you do not need often, but when you do, it is the only tool for the job. 🤘

The controller

The base class is intentionally minimal:

class Controller {
  #element

  constructor(element) {
    this.#element = element

    this.initialize()
  }

  get element() {
    return this.#element
  }

  initialize() {}
  connect() {}
  disconnect() {}
}

The #element private field stores the DOM element. A getter exposes it read-only. Subclasses can access this.element but cannot reassign it.

The real Stimulus calls initialize() once and connect() when the element enters the DOM. This recreation does the same. Subclasses override these methods to add behavior.

All code from this article is in the recreate-stimulus repo.

In a next article: targets, actions and reactive values. Stay tuned. 🤙

Product-minded Rails notes

Once a month: straightforward notes on improving UX in Rails—what to simplify, what to measure, and UI/frontend changes that move real usage.

Over to you…

What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!

Comments are powered by Chirp Form

Want to read me more?