Modest Vue For the HTML You Already Have

Stimulus has a description top and center of its home page: “A modest JavaScript framework for the HTML you already have.” By “modest” I think it is claiming that: it won’t take over your routing, it won’t bury its view code deep in JavaScript code, it can be applied to the pages you choose (and won’t affect the pages that you don’t), etc. By “HTML you already have” I think it is claiming that you can take an HTML-first approach; you can write your HTML and then “sprinkle” in some JavaScript functionality later.

I like Simulus’s proposition, but I don’t like the implementation. So let’s do it in a Ruby on Rails application with Vue instead (source code).

Even though Vue 3 was released in September 2020, Rails still doesn’t support it as an option for creating new applications, so we’ll have to create the app first and then configure it for Vue.

$ rails new modest-vue-for-your-html
$ cd modest-vue-for-your-html

Next add vue, the vue loader (for webpack).

$ yarn add vue@next
$ yarn add vue-loader@next

Now that Vue is installed, it needs some configuration. Edit the webpack configuration file:

config/webpack/environment.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const { environment } = require('@rails/webpacker')
const { DefinePlugin } = require('webpack')
const { VueLoaderPlugin } = require('vue-loader')
const customConfig = {
  resolve: {
    alias: {
      vue: 'vue/dist/vue.esm-bundler.js' // Use the variation that allows Vue to use existing HTML (e.g. Rails views)
    }
  }
}

environment.config.merge(customConfig)

environment.plugins.prepend(
  'Define',
  new DefinePlugin({
    __VUE_OPTIONS_API__: false, // To use the Composition API exclusively (I like it, but this is optional)
    __VUE_PROD_DEVTOOLS__: false // Ensure development code tools stay out of production code
  })
)

environment.plugins.prepend(
  'VueLoaderPlugin',
  new VueLoaderPlugin()
)

environment.loaders.prepend('vue', {
  test: /\.vue$/,
  use: [{ loader: 'vue-loader' }]
})

module.exports = environment

Now that the Vue is configured let’s give it some HTML to work with

$ rails generate scaffold Widget style:string color:string runcible:boolean
$ rails db:migrate

and edit the routes file to use it.

config/routes.rb
1
2
3
4
5
6
Rails.application.routes.draw do
  root 'widgets#index'

  resources :widgets
end

Suppose I want to ensure that people fill out the style name correctly. I could add a Rails validation that the name matches a certain regular expression (and I will later) but that means filling out the form, submitting it, and then discovering a problem. It would be nicer if I got a warning as I was filling the form out. A bit of JavaScript could be nice.

There are a lot of ways to set this up, but since it is a comparison to Stimulus let’s set it up in a Stimulus style. In Stimulus you give an element a specific data-controller attribute and it hooks code to the element. Let’s add a data-component (because Vue uses components) attribute to our HTML and then hook some Vue code to it.

Our Widget views share a common form, so let’s add it to the form partial:

app/views/widgets/_form.html.erb
1
2
3
4
5
6
<div data-component="Widget">
  <%= form_with(model: widget) do |form| %>
    ...
  <% end %>
</div>

Create the component:

app/javascript/components/Widget.js
1
2
3
4
5
6
7
8
9
const Widget = {
  name: 'Widget',
  setup() {
    return {}
  }
}

export default Widget

And add code to hook them together:

app/javascript/packs/application.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// standard Rails stuff
import Rails from "@rails/ujs"
import Turbolinks from "turbolinks"
import * as ActiveStorage from "@rails/activestorage"
import "channels"

Rails.start()
Turbolinks.start()
ActiveStorage.start()

// Vue stuff here
import { createApp } from 'vue'

import Widget from '../components/Widget'
// I could import more components here

const components = { Widget } // An object to hold components

// Normally this would be done with 'DOMContentLoaded', but Turbolinks breaks that
document.addEventListener("turbolinks:load", () => {
  // Find everything with a data-component
  document.querySelectorAll('[data-component]').forEach((node) => {
    // mount the respective Vue component
    createApp(components[node.dataset.component]).mount(node)
  })
})

The plan is to run a check on a form field when it loses focus (onBlur), so let’s start with that. In our component let’s write a function that takes a parameter and logs it out with console.log.

app/javascript/components/Widget.js
1
2
3
4
5
6
7
8
9
10
11
const Widget = {
  name: 'Widget',
  setup() {
    const log = (message) => { console.log(message) }

    return { log }
  }
}

export default Widget

Update your HTML to use the new function:

app/views/widgets/_form.html.erb
1
2
3
4
5
6
7
8
9
10
11
<div data-component="Widget">
  <%= form_with(model: widget) do |form| %>
    ...
    <div class="field">
      <%= form.label :style %>
      <%= form.text_field :style, "@blur" => "log('it got blurry')" %>
    </div>
    ...
  <% end %>
</div>

If you start the server and start up the page, you should be able to click in the first text field, click out of the text field, and see the message in the browser’s console.

This is interesting, but not terribly useful. Let’s assume that widget styles start with “WX-“ and we want to check that in the component:

app/javascript/components/Widget.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Widget = {
  name: 'Widget',
  setup() {
    const check = (name) => {
      if (/^WX-.+/.test(name)) {
        console.log('valid')
      } else {
        console.log('invalid')
      }
    }

    return { check }
  }
}

export default Widget

And hook the new code in:

app/views/widgets/_form.html.erb
1
2
3
4
5
6
7
8
9
10
11
<div data-component="Widget">
  <%= form_with(model: widget) do |form| %>
    ...
    <div class="field">
      <%= form.label :style %>
      <%= form.text_field :style, "@blur" => "check($event.target.value)" %>
    </div>
    ...
  <% end %>
</div>

When you try out the modified page you should see “valid” and “invalid” when you type different strings into the text field and remove focus.

This is nice, but users aren’t going to look in their web console for messages. Let’s output a message into the HTML. First we’ll create a Vue ref in our Widget:

app/javascript/components/Widget.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { ref } from 'vue'

const Widget = {
  name: 'Widget',
  setup() {
    var errorMessage = ref(null)

    // ...

    return { errorMessage, check }
  }
}

export default Widget

And add a corresponding div to the HTML:

app/views/widgets/_form.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
<div data-component="Widget">
  <%= form_with(model: widget) do |form| %>
    ...
    <div class="field">
      <%= form.label :style %>
      <%= form.text_field :style, "@blur" => "check($event.target.value)" %>
      <div ref="errorMessage" style="color: #C00"></div>
    </div>
    ...
  <% end %>
</div>

Which leads to the final version of the component:

app/javascript/components/Widget.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { ref } from 'vue'

const Widget = {
  name: 'Widget',
  setup() {
    var errorMessage = ref(null)

    const check = (name) => {
      if (/^WX-.+/.test(name)) {
        errorMessage.value.textContent = ''
      } else {
        errorMessage.value.textContent = 'This style name is incorrect'
      }
    }

    return { errorMessage, check }
  }
}

export default Widget

And here is the complete, final version of the form partial:

app/views/widgets/_form.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<div data-component="Widget">
  <%= form_with(model: widget) do |form| %>
    <% if widget.errors.any? %>
      <div id="error_explanation">
        <h2><%= pluralize(widget.errors.count, "error") %> prohibited this widget from being saved:</h2>

        <ul>
          <% widget.errors.each do |error| %>
            <li><%= error.full_message %></li>
          <% end %>
        </ul>
      </div>
    <% end %>

    <div class="field">
      <%= form.label :style %>
      <%= form.text_field :style, "@blur" => "check($event.target.value)" %>
      <div ref="errorMessage" style="color: #C00"></div>
    </div>

    <div class="field">
      <%= form.label :color %>
      <%= form.text_field :color %>
    </div>

    <div class="field">
      <%= form.label :runcible %>
      <%= form.check_box :runcible %>
    </div>

    <div class="actions">
      <%= form.submit %>
    </div>
  <% end %>
</div>

Final observations:

  • I think this code is quite readable. You can look at the HTML and get a good sense of what the JavaScript will do. You can look at the JavaScript and get a good sense of what will happen in the HTML.
  • I don’t have to reason about JavaScript to know what the resulting HTML is going to be.
  • This is easy to backtrace. If you “view source” on this in a browser it is finding the corresponding partial in your Rails views and component in your JavaScript should be pretty easy.
  • It is easy to see what is being passed to the JavaScript functions.
  • I chose a naming scheme and setup that made sense to me, but there are a lot of different approaches that would work.
  • Vue is a “progressive” framework. That means that you can add more techniques as needed. If you want to evolve this into a single page application you can. If you want to add child components to this component you can. If you want to start using single file components you can (I like this tutorial: https://dev.to/vannsl/vue3-on-rails-l9d).
  • Because the components are just objects with functions, it would be easy to extract this into something reusable. Imagine turning the check function into a host of functions (e.g. matches, presence, numericality) similar to Rails validators that you can import from a useValidation composable.
  • I didn’t touch on testing (maybe my next blog post), but Vue has a lot of documentation on testing, and utilities for the major testing frameworks. This example could be tested with Jest and mounting the component with Vue testing utilities. As I stated before, you could extract the code into a useValidation composable and test the functions, which is even more straightforward.