Using Vue.js as an Alternative to Rails Nested Forms

  • tutorials, vue.js
  • 10 Comments

When I need to handle multiple models in the same Rails form, sometimes I prefer to use Vue.js. When users need the ability to add one or many child associations to a parent model, Vue.js can provide more flexibility than Rails's :accepts_nested_attributes_for.

For this tutorial, I'm using a simple hockey teams app, where a team has_many players and a player belongs_to a team (code here). The objective is to build a form that allows a user to create a team and add one or many players to that team within the same form. This common pattern provides a better user experience than having separate forms for two different models.

Introducing Vue to Rails

Vue.js is a lightweight yet powerful JavaScript framework the allows you to add single-page app functionality (like two-way binding) to a Rails app. Adding it to a Rails project is simple: download a copy from Vuejs.org and add it to vendor/assets/javascripts. After that I require Vue in the asset pipeline and remove Turbolinks in assets/javascripts/application.js to prevent any possible conflicts between Vue and Turbolinks (this step isn't required, though).

//= require jquery
//= require jquery_ujs
//= require vue
//= require_tree .

Create REST API

I need to create routes that will send requests to an API controller I'll create a little later. For now, I'll just setup the API endpoints in the nested namespace routes and direct all users to the new team and player form at the root route:

#routes.rb
Rails.application.routes.draw do
  root 'teams#new'
  namespace :api do
    namespace :v1 do
      get '/teams' => 'teams#index'
      post '/teams' => 'teams#create'
    end
  end
end

Get Vue Up and Running

Inside assets/javascripts I'll create a Vue controller in a file called teams_ctrl.js and I'll add the following code:

new Vue({
  el: '#new-team-form',
  data: {
    message: 'Vue is working!'
  }
})

I'll wrap the above code in $(document).on('ready', function() {}) so the Vue code runs only after the DOM has loaded (otherwise, Vue would be looking for elements that aren't present yet):

$(document).on('ready', function() {
  new Vue({
    el: '#new-team-form',
    data: {
      message: 'Vue is working!'
    }
  })
})

In views/teams I'll create new.html.erb template with this simple snippet to test if Vue is working properly:

<div id="new-team-form">
  {{ message }}
</div>

When I fire up Rails server and go to http://localhost:3000/ I will see “Vue is working!” If you see {{ message }} instead, compare your code to what I have above and look for inconsistencies.

Once Vue is up and running, I can start writing the actual form code. I'll delete {{ message }} and add labels and inputs for the teams and players attributes, and buttons for adding new instances of a player and persisting the team and its players to the database:

<div id="new-team-form">
  <label for="name">Team Name: </label>
  <input id="name" v-model="name">
    {{ errors }}
  <div v-for="player in players">
    <label for="name">Player Name: </label>
    <input id="name" v-model="player.name">
    <label for="position">Player Position: </label>
    <input id="position" v-model="player.position">
  </div>
  <button v-on:click="addPlayer">Add Player</button>
  <button v-on:click="saveTeam(name)">Save Team</button>
</div>

The v-model creates two-way data bindings on form input and will allow users to create team names, player names, and player positions and store them in the database. Every time the “Add Player” button is clicked, a new player instance with inputs for their name and position will be displayed in the form so users will be able to add 1, ten, or twenty players to a team at once. The “Save Team” button will send a post request to the database and create the new team and players.

Controller Actions

I already have the routes setup but I need a controller where I can send requests. In app/controllers I'll create an api folder and inside that folder I'll create a v1 folder. Inside the v1 folder I'll add a file called teams_controller.rb and this is where the create action for the new team and players will reside.

The code in the create action is pretty straightforward—I instantiate a new team object, pass in the name params, and then I iterate through every player instance and pass in the name and position as params. I will also include a conditional with a happy path for when the team is saved successfully and a sad path that will trigger an error message if the team name field is left blank.

class Api::V1::TeamsController < ApplicationController
  def create
    @team = Team.new(
      name: params[:name]
    )
    players = JSON.parse(params[:players])
    players.each do |player|
      @team.players.new(
        name: player["name"],
        position: player["position"]
      )
    end
    if @team.save
      render 'show.json.jbuilder'
    else
      render json: { errors: @team.errors.full_messages }, status: 422
    end
  end

  def show
    @team = Team.find(params[:id])
  end
end

If you use Rails, the show action is familiar to you, but to allow it to render team and player data on the show page template, I need to create show.json.jbuilder in the app/views/api/v1/teams directory and add the following Jbuilder code for nesting players inside teams:

json.(@team, :id, :name)
json.players @team.players, :name, :position

Building out the Vue Controller

In the data option I should pre-initialize the name property to optimize the performance and reactivity (you don't have to do this, but Vue will generate a warning in the console if you don't), and I set teams, players, and errors objects to empty arrays where I'll push data.

Inside the methods: I have the saveTeam and addPlayer functions I referenced in the form, and an AJAX call to post the form data to the database. If all goes well, I will redirect the user with window.location = "/teams/" + result.id; to a show page that displays the newly created team's name and any players with their names and positions.

$(document).on('ready', function() {
  new Vue({
    el: '#new-team-form',
    data: {
      name: "",
      teams: [],
      players: [],
      errors: []
    },
    methods: {
      saveTeam: function(name) {
        var params = {
          name: name,
          players: JSON.stringify(this.players)
        };
        $.post('/api/v1/teams.json', params).done(function(result) {
          window.location = "/teams/" + result.id;
        }.bind(this)).fail(function(result) {
            this.errors = result.responseJSON.errors;
        }.bind(this));
      },
      addPlayer: function() {
        this.players.push({
          name: '',
          position: ''
        })
      }
    }
  })
})

And that is all you need to create a form in Rails that hits two different models, without relying on Rails nested forms.

In Production

I deployed the code I used for this tutorial to Heroku. Check out the form here and add a team and as many players as you like.