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.
Make JavaScript your second favorite language
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. 🤙
Want to read me more?
-
Stimulus basics: what is a Stimulus controller?
Learn about the basics of Stimulus controller: that they are really just plain JavaScript classes. -
Why Disconnect in Stimulus Controllers
The disconnect lifecycle method in Stimulus helps you clean and teardown your code. Keepin things snappy. -
Stimulus Features You (Didn't) Know
Stimulus is advertised as a modest JavaScript framework, but packs still quite a few features. Lets explore the lesser known ones.
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
{{comment}}