Custom Actions in Attractive.js: one interface, from small to big

In last week’s announcement, I promised that when the built-in actions aren’t enough, you can write your own. Let me show you how, because this is what was bugging me most: why pull in another library when Attractive.js can do it as well?

Next to that I will also highlight some extra goodies and the bundled extensions that can now be (optionally) added.

Start with a one-liner

The quickest custom action is the js: prefix. It evaluates a JavaScript expression on the element, so you can do things too small for a full action:

<button @click="js:this.textContent=1">0 (I will become 1)</button>

Use it for one-liners only. It goes through the full action pipeline (so all events works as well as data-debounce and data-delay). It runs new Function() under the hood (which needs unsafe-eval in your CSP).

A custom action as a function

The step up from js: is a plain function. Register it with addActions on activate():

import Attractive from "attractivejs";

const sharePage = (element, { dataset }) => {
  const title = dataset.shareTitle || document.title;
  const url = dataset.shareUrl || window.location.href;

  if (navigator.share) {
    navigator.share({ title, url });
  } else {
    // write to clipboard or however you want to handle this
  }
};

Attractive.activate({
  addActions: { sharePage }
});
<button @action="sharePage" data-share-title="Check this out">Share</button>

The function receives the element and a context object with value, target, targets, event and dataset. In a Rails app you’d put this in app/javascript/application.js and you’re done. The markup stays identical to a built-in action.

A custom action as a class

When an action grows beyond a reasonable size, it becomes a class. Any class with a run() method works:

class Reorder {
  run() {
    this.element;  // the element with @action
    this.options;  // { value, target, targets, event, dataset, … }
  }
}

Register it the same way as the function:

Attractive.activate({
  addActions: { reorder: Reorder }
});

There’s an optional Action base class that gives you value, dataset and a dispatchEvent helper:

import { Action } from "attractivejs";

export default class Reorder extends Action {
  run() {
    this.value;  // the string after # in @action="reorder#…"
    this.dataset;

    this.dispatchEvent("reorder:done", { ids: [1, 2, 3] });
  }
}

Notice what didn’t change: registration is the same as the function version. You started with a one-liner, then a function, then a class and the interface stayed the same the whole way. Pretty cool, right?

Organizing actions

When you have a handful of actions, keep each in its own file and collect them in a barrel (same as how Stimulus is organized):

app/javascript/
  actions/
    reorder.js
    syntax_highlighting.js
    index.js

Each file exports a single function or class as default. The barrel collects them:

// app/javascript/actions/index.js
import syntax_highlighting from "./syntax_highlighting.js";
import reorder from "./reorder.js";

export default { syntax_highlighting, reorder };

Then your application file imports the barrel and activates:

import Attractive from "attractivejs";
import actions from "./actions/index.js";

Attractive.activate({ addActions: actions });
Minimal and Powerful CMS for Static Sites

Work together with your team on all your content, from blog, to docs to changelog.

Start trial

Hooks and error handling

Hooks let you run code around every action and handle errors in one place.

beforeAction runs before each action, return false to cancel it. afterAction runs after a success. onError handles a thrown action:

attractive.onError(({ name, element, options, event, error }) => {
  console.warn(`Action ${name} failed:`, error.message);
});

There are no unhandled rejections, the rest of a chained action still runs. There’s a global Attractive.onError fallback too, handy for wiring up a monitoring service.

The extensions

Attractive ships a few optional extensions you opt into with extendWith.

Keyboard

The keyboard extension gives you @keydown.enter, combos like @keydown.ctrl+k and global @hotkey shortcuts:

import { keyboard } from "attractivejs/keyboard";

Attractive.activate({ extendWith: [keyboard] });
<input @keydown.mod+enter="submit" @target="form">

<a href="/" aria-hidden="true" @hotkey.g.h="/">Homepage</a>

Validate

The validate extension brings client-side form validation using the browser’s native Constraint Validation API. Just add @validate to a form and style the invalid state:

import { validate } from "attractivejs/validate";

Attractive.activate({ extendWith: [validate] });
input:user-invalid {
  border-color: red;
}

Reactive

The reactive extension adds a shared JSON store with @text bindings and a setStore:

<input @input="setStore#name" />

<p>Hello, <span @text="name"></span></p>

Attractive Element

Attractive Element is the last piece I want to highlight today: a custom element base class. It scopes Attractive to a component, so actions and targets work inside it without a connectedCallback, a querySelector or manual teardown.

import { AttractiveElement } from "attractivejs/element";

class Counter extends AttractiveElement {
  connect() {
    this.count = 0;
  }

  increment() {
    this.target("count").textContent = ++this.count;
  }
}

customElements.define("ui-counter", Counter);
<ui-counter>
  <button @click="increment">+</button>

  <output id="count">0</output>
</ui-counter>

connect() and disconnect() replace the lifecycle callbacks. Methods in the HTML become actions. this.target("id") and this.targets(".selector") query within the component, so each component resolves its own targets even when the same id appears twice. It works alongside a document-wide activation without actions firing twice.


One interface for everything, from a one-liner to a function to a class to a custom element. That’s why I am so excited about Attractive: it works neatly alongside your bigger frameworks but can also stand on its own feet.

Everything you need is in the docs and the repo is always open for a star and improvement and bugfixes. ☺️

Next week, it comes all together: all of this inside a real Rails app (optimistic UI included 🤫).

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?