DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com on

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"]
}

Enter fullscreen mode Exit fullscreen mode

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}"]`)
      }
    })
  }
}

Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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

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}`)
  }
}

Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
})

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)