loading...
Cover image for Your own stimulus-like framework in 10 minutes [Part 1]

Your own stimulus-like framework in 10 minutes [Part 1]

paveltkachenko profile image Pavel Tkachenko ・6 min read

Basecamp introduced very simple and powerful framework to make cool JS stuff. It is awesome to use, especially when you don't need the overwhelming React/Vue/Angular and you don't like jQuery spaghetti code. At first glance it looks like Rails magic, because many things are implemented using convention-over-configuration principle.

Let's call it Stimulator

I want you to build your own Stimulus-like framework right now with me. It's not a replacement of Stimulus, it has a lot of under-the-hood troubles, but we will implement all features of Stimulus and add more (which will be released in Stimulus 2.0). I will try to show you the simplest solution, step by step, so any JS beginner can understand the flow. Each step has a snapshot on github, where you can look it up in case you're lost.

If you are not familiar with Stimulus, please refer to https://stimulusjs.org/ and read small guide. I don't want to overwhelm this article with Stimulus concepts, so I expect that you already know them.

Ok, let's define what we want to implement. I took the very basic example from https://stimulusjs.org/ and adapted it a little bit to the structure that we will implement. Let's name our framework Stimulator!

<div data-controller="Hello">
  <input data-target="Hello.name" type="text">

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

  <span data-target="Hello.output">
  </span>
</div>
// We will not use modules and import to simplify this tutorial
// import { Controller } from "Stimulus"

class extends Controller {
  // In stimulus you have to define targets like this
  // static targets = [ "name", "output" ]
  // but we will do it automatically

  greet() {
    this.outputTarget.textContent =
      `Hello, ${this.nameTarget.value}!`
  }
}

File structure

Let's start to build gradually. You don't need node_modules, gulp, yarn and all these heretic stuff. Create folder stimulator, and one file in it: index.html. Create html for our controller:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Stimulator</title>
</head>
<body>
  <!-- Our empty controller -->
  <div data-controller="Hello">

  </div>
</body>
</html>

Register controller

Now we need our Stimulator to find our Contoller. Create three files index.js, Controller.js and HelloController.js in script folder;

// script/Controller.js
// Here we will have all our logic
class Controller {

}

// script/HelloController.js
// Every controller you define in html page
// must have appropriate class.
class HelloController extends Controller {

}

// script/index.js
// We need it now only to initialize HelloController
new HelloController();

Don't forget to include your scripts in index.html. Set attribute defer, it will initialize your scripts after DOM will be ready.

<head>
  <!-- ... -->
  <script src="script/Controller.js" defer></script>
  <script src="script/HelloController.js" defer></script>
  <script src="script/index.js" defer></script>
</head>

Current code snapshot: https://github.com/PavelTkachenko/stimulator-dev-to/tree/file_structure

As you remember we need to find our controller on index.html page.

class Controller {
  constructor() {
    // Set our controller name
    this._setControllerName();
    // Find it (node) on the page
    this._setContainer();
  }

  // We just take our class name (e.g. HelloController) and
  // remove word "Controller" from it.
  // So our this._name is "Hello" now
  _setControllerName() {
    this._name = this.constructor.name.substr(0, this.constructor.name.length - 10);
  }

  // After we obtained name of the controller, we can find it on the page
  _setContainer() {
    this._container = document.querySelector(`[data-controller="${this._name}"]`);
  }
}

As you can see I use lodash(_) before properties and methods. It's a convention of declaring private properties and methods, because JS doesn't have them by design.

Open your index.html in browser, enter the developer console and initialize your HelloController by printing new HelloController();. You can see that controller successfully registered name and container.

Register Controller

Register targets

Next we need to register our targets. Expand your HTML part of controller.

<div data-controller="Hello">
  <input data-target="Hello.name" type="text">

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

  <span data-target="Hello.output">
  </span>
</div>

Now we have two targets Hello.name and Hello.output. We need targets to easily find them in our Controller class.

Add new method _registerTargets to Controller base class:

  _registerTargets() {
    // Find all nodes with data-target attribute
    const targetElements = this._container.querySelectorAll("[data-target]");

    // Loop over nodes 
    Array.from(targetElements).forEach(element => {
      // Get value from data-target and add ability to define
      // more than 1 target separating them with ","
      // e.g. data-target="Hello.name,OtherController.foo"
      const dataTargets = element.getAttribute("data-target").split(",");
      // Loop over such targets
      dataTargets.forEach(dataTarget => {
        // Extract controller and target name
        const [controller, target] = dataTarget.split(".");
        // Assign target to controller if it belongs to it
        if (controller === this._name) {
          // e.g. For hello.name we now have
          // nameTarget property
          this[`${target}Target`] = element;
        }
      })
    });
  }

Don't forget to invoke method in your constructor

constructor() {
  this._setControllerName();
  this._setContainer();
  // Register our targets
  this._registerTargets();
}

Now check that your Controller can handle targets. Go to browser console, type new HelloController(), and you will see all targets in it.

Register targets

Current code snapshot: https://github.com/PavelTkachenko/stimulator-dev-to/tree/register_targets

Register actions

Almost done. Finally we need to register our actions. Add method _registerActions to Controller.js. It is very similar to _registerTargets:

_registerActions() {
  // Very similar to _registerTargets, but
  // we also need to extract trigger to create
  // appropriate event listener
  const actionElements = this._container.querySelectorAll("[data-action]");
  Array.from(actionElements).forEach(element => {
    const dataActions = element.getAttribute("data-action").split(",");
    dataActions.forEach(action => {
      const trigger = action.split("->")[0];
      const funcName = action.split("#")[1];
      element.addEventListener(trigger, (e) => {
        // If function is defined in your Controller
        // it will be called after event triggered
        if (this[funcName] !== undefined) {
          this[funcName](e);
        }
      });
    })
  });
}

Don't forget to invoke method in constructor:

  constructor() {
    this._setControllerName();
    this._setContainer();
    this._registerTargets();
    // Register our actions
    this._registerActions();
  }

Now our framework is ready. Let's test it with our HelloController. Add method greet to it:

class HelloController extends Controller {
  greet() {
    this.outputTarget.textContent =
      `Hello, ${this.nameTarget.value}!`
  }
}

Current code snapshot: https://github.com/PavelTkachenko/stimulator-dev-to/tree/register_actions

Go to browser and check how it works!

Alt Text

Color mixer

Let's test our framework with a more difficult task. It will be color mixer, which produces color from Red, Green and Blue inputs. Also it has "Random" button to generate random color.

Let's start with our layout.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Stimulator</title>
  <script src="script/Controller.js" defer></script>
  <script src="script/ColorController.js" defer></script>
  <script src="script/index.js" defer></script>
  <style>
    .field {
      width: 200px;
      text-align: right;
    }

    .result {
      height: 200px;
      width: 200px;
    }
  </style>
</head>
<body>
  <div data-controller="Color">
    <div class="field">
      <label for="red">Red</label>
      <input name="red" type="number" min="0" max="255" data-target="Color.red">
    </div>
    <div class="field">
      <label for="green">Green</label>
      <input name="green" type="number" min="0" max="255" data-target="Color.green" >
    </div>
    <div class="field">
      <label for="blue">Blue</label>
      <input name="blue" type="number" min="0" max="255" data-target="Color.blue">
    </div>
    <div class="field">
      <button data-action="click->Color#mix">Mix colors</button>
      <button data-action="click->Color#random">Random</button>
    </div>
    <div class="result" data-target="Color.result"></div>
  </div>
</body>
</html>

Add our controller with logic.

class ColorController extends Controller {
  mix() {
    const r = this.redTarget.value;
    const g = this.greenTarget.value;
    const b = this.blueTarget.value;

    this.resultTarget.style.background = `rgb(${r},${g}, ${b})`;
  }

  random() {
    this.redTarget.value = this.randomInt(0, 255);
    this.greenTarget.value = this.randomInt(0, 255);
    this.blueTarget.value = this.randomInt(0, 255);

    this.mix();
  }

  randomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }
}

Look! Works like a charm:

Alt Text

Current code snapshot: https://github.com/PavelTkachenko/stimulator-dev-to/tree/color_mixer

That's all for today folks. Next time we will add storage using data attributes (props), add auto-detect changing for our props, lifecycles and even more. As you can see implementation is very simple, it is not suitable for production of course. Main point here is that you can easily experiment and prototype different cool things. Maybe some day you will create a next-gen JS framework, which will be used by developers in every part of planet Earth and beyond.

Posted on Jul 4 by:

paveltkachenko profile

Pavel Tkachenko

@paveltkachenko

Ruby Fullstack Developer. In love with Ruby and Rails. Sometimes happy with JS.

Discussion

markdown guide