Recreating Stimulus: targets, actions and reactive values

This is part 2 of a two-part series. Part 1 covered controllers and lifecycle.

Controllers connect and disconnect. That is not useful on its own. The value comes from three things: referencing elements inside the controller, handling events without inline JavaScript and reacting to data changes. Stimulus calls these targets, actions and values.

Each maps to a distinct JavaScript feature. And none of them repeat the tricks from part 1.

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

Targets: dynamic getters

A target is a named element inside a controller’s scope. Declare it in the class and get a getter for free:

class HelloController extends Controller {
  static targets = ["name", "output"]
}

The getter this.nameTarget returns the first element with data-hello-target="name" inside the controller element. The plural this.nameTargets returns all of them. The existence this.hasNameTarget returns true or false.

These getters do not exist at compile time. They are created dynamically using Object.defineProperty:

export function wireTargets(controller, identifier) {
  for (const name of controller.constructor.targets) {
    Object.defineProperty(controller, `${camelize(name)}Target`, {
      get() {
        return this.element.querySelector(`[data-${identifier}-target="${name}"]`)
      }
    })

    Object.defineProperty(controller, `${camelize(name)}Targets`, {
      get() {
        return this.element.querySelectorAll(`[data-${identifier}-target="${name}"]`)
      }
    })
  }
}

Object.defineProperty defines a property on an existing object with configurable behavior (in Ruby, you’d use define_method or attr_accessor with a custom getter to achieve similar dynamic property behavior). The getter runs every time the property is accessed, so the query is always live. If the DOM changes, the getter returns the updated result. No manual refresh needed.

This is different from a regular property assignment. With controller.nameTarget = value you set a static value. With Object.defineProperty you control every aspect: get, set, enumerable and configurable (in Ruby, this level of control over property access would be achieved using method_missing or define_method with custom logic to intercept and control all aspects of property access).

Actions: event delegation

Actions wire DOM events to controller methods. In HTML you write:

<button data-action="click->hello#greet">Greet</button>

No addEventListener in sight. The framework handles it with a single document-level listener per event type. This is event delegation, and it works like this:

export function energize(lookup) {
  controllerLookup = lookup

  for (const eventType of ["click", "submit", "change", "input", "keydown", "keyup"]) {
    document.addEventListener(eventType, spark)
  }
}

function spark(event) {
  const actionElement = event.target.closest("[data-action]")
  if (!actionElement) return

  const raw = actionElement.getAttribute("data-action")
  const descriptors = raw.split(" ").map(part => part.trim()).filter(Boolean)

  for (const descriptor of descriptors) {
    const route = deconstruct(descriptor, event.type)
    if (!route) continue

    const controllerElement = actionElement.closest(`[data-controller~="${route.identifier}"]`)
    if (!controllerElement) continue

    const controller = controllerLookup(controllerElement)
    if (!controller) continue

    if (typeof controller[route.methodName] === "function") {
      controller[route.methodName](event)
    }
  }
}

One listener per event type on document. No matter how many action-annotated elements are on the page. When a click fires, event.target.closest("[data-action]") walks up the DOM tree to find the nearest element with an action attribute. If it finds one, the descriptor is parsed and the right method is called.

The parsing is straightforward string work with split and trim:

function deconstruct(descriptor, eventType) {
  const parts = descriptor.split("->")

  if (parts.length === 2) {
    if (parts[0].trim() !== eventType) return null

    const rest = parts[1].split("#")

    return { identifier: rest[0].trim(), methodName: rest[1].trim() }
  }

  return null
}

This is the old-school JavaScript that never goes out of style. No regex, no framework. Just split, trim and a conditional. 🧓

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

Values: reactive data

Values bring reactivity. Declare them with a type and get getter, setter and change callback:

class HelloController extends Controller {
  static values = { count: Number }

  countValueChanged(current, previous) {
    console.log(`Count went from ${previous} to ${current}`)
  }
}

Setting this.countValue = this.countValue + 1 updates the attribute data-hello-count-value on the DOM element and fires countValueChanged with the old and new values.

The implementation uses Object.defineProperty again, this time with both a getter and a setter:

Object.defineProperty(controller, propertyName, {
  get() {
    return stored
  },

  set(value) {
    const previous = stored
    if (value === previous) return

    stored = value
    controller.element.setAttribute(attributeName, serialize(value, type))

    const changedCallback = `${camelize(name)}ValueChanged`
    if (typeof controller[changedCallback] === "function") {
      controller[changedCallback](value, previous)
    }
  }
})

The getter returns a cached value. The setter stores the new value, syncs it to the DOM attribute and calls the change callback if it exists. The if (value === previous) return guard prevents unnecessary updates.

Type coercion happens through a helper that maps JavaScript constructors to coercion logic:

function read(raw, type) {
  if (type === Number) return raw === "" || raw === null ? null : Number(raw)
  if (type === Boolean) return raw === "true" || raw === "1"
  if (type === Array || type === Object) {
    try { return JSON.parse(raw) } catch { return raw }
  }
  return raw
}

Number("42") returns 42. JSON.parse("[1,2,3]") returns an actual array. "false" as Boolean returns… well, Boolean("false") would return true (non-empty string is truthy). So we check the string value directly. These are the small traps that make framework development interesting. 😬


Three features, three JavaScript concepts. Object.defineProperty for dynamic accessors. Event delegation via closest(). Static class fields for declarative configuration. Plus a dash of JSON.parse and Number for type coercion.

All code from this article (and the previous one) is in the recreate-stimulus repo.

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?