•   #vue   •   #phoenix   •   #elixir

Rendering Vue.js Directly In Phoenix

Phoenix Framework is incredibly fast at serving server-rendered content. I generally prefer to render as much as possible on the server when given the option. However, UIs often provide a much better user experience in certain cases with dynamic elements.

In Progress Plum, I took the time to learn Vue.js because of its small file size as well as its ability to use templates defined in plain HTML. As I quickly discovered, you can render Vue.js components inside server-rendered content and partially fill component state directly using Phoenix. Combining Phoenix's server-rendered speed with the ability to fill Vue.js state in Phoenix is a game-changer for my UI development. I'm going to show you too can take advantage of this.

If you're unfamiliar with Vue, take a look at Vue's Guide. A quick read should help you digest the content.

Setting Up Vue.js

From within the assets/ directory of your application using Phoenix, run:

npm install vue --save

You'll also need to make Vue available to the browser from your application's javascript file. Make sure the following is in your app.js:

import Vue from 'vue';
window.Vue = Vue; 

Usage in Phoenix Templates

Whenever you want to use Vue in a specific area of your application, you'll need to create a mounting point for your Vue instance.

<div id="vue-app">
</div>

With a DOM element in place, you can attach your instance by directly writing Javascript on the page. (this probably isn't best practice but it works for my use-case)

<script>
  new Vue({
    el: '#vue-app'
  });
</script>

You can also define templates within the DOM or inline depending on your needs and mix them with components.

<script type="text/x-template" id="hello-world">
   <span>Hello, world!</span>
</script>

<script>
  Vue.component('hello-world', {
    template: '#hello-world'
  });
</script>

Setting Vue State from Phoenix

In Progress Plum, there are several places where I want to provide a way dynamically select users. The user list is members of a Slack Workspace that I load directly into the state of a Vue instance. This is where the beauty of Vue and Phoenix really shines.

The Vue Component

The user-select component is responsible for displaying a list of users, providing a way to filter for users, displaying only selected users, and to keep track of which users are to be selected.

This is the the template for my component. You might notice that I also mix in some "components" (not to be confused with Vue components) which are helpers for rendering other EEx templates.

<div style="margin-bottom: 1rem;">
  <div class="field" slot="subheader">
    <div class="control has-icons-left">
      <input
        class="input solid"
        type="text"
        placeholder="Search by name, username, or email."
        v-model="filter"
      />
      <span class="icon is-small is-left">
        <%= Components.icon "search" %>
      </span>
    </div>
  </div>

  <div style="margin-bottom: .75rem;">
    <div
      class="toggle toggle--color-success"
      v-bind:class="{'toggle--state-active': showSelectedOnly}"
      v-on:click="toggleShowSelectedOnly"
    >
      <span>Only Selected</span>
    </div>
  </div>

  <div class="grid grid-2 grid-gap">
    <div
      class="user user--selectable user--border"
      v-for="user in filteredUsers"
      v-bind:class="{'user--selected': isSelected(user.id)}"
      v-on:click="toggleUser(user.id)"
    >
      <img v-bind:src="user.avatar"/>
      <div class="info">
        <span class="text-color-title text-bold">{{user.name}}</span>
        <span class="text-color-subtitle">{{user.display_name}}</span>
      </div>
      <%= Components.icon "check-circle", type: "solid" %>
    </div>
  </div>
</div>

Here's the Javascript portion for this component. The main aspects this component tracks is an input filter, a toggle for showing only selected users, and this list of selected users.

Vue.component('user-select', {
  template: "#user-select",
  props: {
    users: {
      type: Array,
      default: () => []
    },
    initialSelectedUsers: {
      type: Array,
      default: () => []
    }
  },
  data: function() {
    return {
      filter: "",
      showSelectedOnly: false,
      selectedUsers: this.initialSelectedUsers.slice()
    };
  },
  computed: {
    filteredUsers: function() {
      let users = this.users.slice();

      if (this.showSelectedOnly) {
        const selectedUsers = this.selectedUsers;
        users = users.filter(user => selectedUsers.includes(user.id));
      }
       if (this.filter === "") {
        return users;
      }
       const filter = this.filter;
       return users.filter(user => {
        return (user.name || "").toLowerCase().includes(filter) ||
               (user.email || "").toLowerCase().includes(filter) ||
               (user.displayName || "").toLowerCase().includes(filter);
      });
    }
  },
  methods: {
    isSelected: function(userId) {
      return this.selectedUsers.includes(userId);
    },
    toggleShowSelectedOnly: function() {
      this.showSelectedOnly =!this.showSelectedOnly;
    },
    toggleUser: function(userId) {
      const index = this.selectedUsers.indexOf(userId);
      if (index != -1) {
        this.selectedUsers = this.selectedUsers.filter(id => id !== userId);
      }
      else {
        this.selectedUsers.push(userId);
      }
      this.$emit("selected-users-updated", this.selectedUsers);
    }
  }
});

Mounting the Component With Data From Phoenix

I can leverage this component by feeding the data to it directly from Phoenix. Here's a snippet of a page where I feed the data directly.

<%= Components.box medium: true, id: "admin-invite" do %>
  <%= Components.box_header title: "Invite Users", icon: "user-plus" do %>
    <button
      class="button is-success"
      v-bind:disabled="!submitable"
      form="invite-form"
      type="submit"
    >
      Invite
    </button>
  <% end %>
  <%= Components.box_content do %>
    <user-select
      v-bind:users="users"
      v-on:selected-users-updated="updateSelectedUsersList"
    ></user-select>

    <%= form_for @conn, AppRoutes.team_path(@conn, :invite_users), [id: "invite-form"], fn _f -> %>
      <input
        v-for="user_id in selectedUsers"
        type="hidden"
        name="invites[]"
        v-bind:value="user_id"
      />
    <% end %>
  <% end %>
<% end %>

<script>
 new Vue({
   el: "#admin-invite",
   data: {
     users: <%= raw(Jason.encode!(@slack_info.users)) %>,
     selectedUsers: []
   },
   computed: {
     submitable: function() {
       return this.selectedUsers.length > 0;
     }
   },
   methods: {
     updateSelectedUsersList: function(users) {
       this.selectedUsers = users;
     }
   }
 });
</script>

Let's breakdown what's going on here:

  1. A small section where a Vue instance should be mounted is rendered in the DOM. Within that section is a Vue component as well as some other elements leveraging Vue.
  2. A Vue instance is mounted to the DOM element. The data portion of the instance is loaded with a list Slack users that are encoded into JSON directly. This data is bound to the user-select component that I defined earlier.
  3. The Vue instance listens for data changes coming from the user-select component and updates its internal list. The length of this list control whether or not the form on the page can be submitted.

Notice that there's no need to make an API request to the application just to provide data to our Javascript! Once our Vue instance in mounted, everything works like magic. Here's what the rendered component looks like.

A rendered Vue instance with rendered component

Wrapping Up

Vue and Phoenix make a powerful pair. Being able to mix dynamic content with the speed of server-rendered content is a huge boost in productivity. Vue's simplicity and tooling really shines in this scenario. You can play with the idea of rendering JSON directly to the DOM where it makes sense for your application.

Alex Garibay's Picture
Alex Garibay