has_many :through at a high-level.

A has_many :through association is how we setup many-to-many connections with another model. When we define a has_many :through relationship on a model, we’re saying that we can potentially link the model to another model by going through a third model. I think the Rails guides do a great job of illustrating an example of how to use this relationship. Let’s think of a different example. Say we have a company, a company has employees through an employment.

class Company < ApplicationRecord
  has_many :employments
  has_many :employees, through: :employments
end

class Employment < ApplicationRecord
  belongs_to :employee
  belongs_to :company
end

class Employee < ApplicationRecord
  has_many :employments
  has_many :companies, through: :employments
end

Our migrations would look like this:

class CreateCompanies < ActiveRecord::Migration[6.0]
  def change
    create_table :companies do |t|
      t.string :name
      t.timestamps
    end

    create_table :employees do |t|
      t.string :name
      t.timestamps
    end

    create_table :employments do |t|
      t.belongs_to :employee
      t.belongs_to :company
      t.datetime :start_date
      t.datetime :end_date
      t.string :role
      t.timestamps
    end
  end
end

When an employee “leaves” a company, we don’t have to create the employee all over again. We make a new employment linked to their employee record and their new company.

When should I reach for has_many :through

In the example above, we touched on when you should go for this association type. We don’t want an employee tied to a company. We want there to be some intermediary relationship that links the two things together. In this case, it’s because we want to store some extra data on the employment table, like start date, end date, and role name. An employee will have multiple employments throughout their life. Now we have a table that keeps track of them. A relationship allows us to ask employee.companies and shortcut through the employments relationship to grab all the companies for which an employee has worked. My advice is to reach for has_many :through when you need additional data on the association.

Introducing Pantry

I’m big on meal prepping, recipe saving, and cooking in general. I thought a recipe organization application would be a great example because it is interesting, fresh (not a to-do app!), and provides some potential challenges.

Pantry will be the name of the application that we’ll be building to illustrate how to use has_many :through, nested forms, nested_attributes, and dynamic fields using a third-party library. Let’s get started.

First, we need to set requirements, expectations and then think through this application’s business logic/relationships.

Pantry is a proof-of-concept application. There will be no extraneous styling or interactivity. We’re talking bare bones to illustrate a concept only. We will create a recipe using a form, and a recipe has a name and ingredients. Ingredients have a name, an amount, and a description.

I think our relationships will likely look something like this:

Diagram showing the relationships between recipes, recipe_ingredients, and ingredients. recipes have an integer column id and string column title, recipes has_many :recipe_ingredients and has_many :ingredients, through: :recipe_ingredients. ingredients has an integer column id and string column name, ingredients has_many :recipe_ingredients and has_many :recipes, through: :recipe_ingredients. a recipe_ingredient has integer column id, integer column recipe_id, integer column ingredient_id, string column amount, and string column description, recipe_ingredients belongs_to :recipe and recipe_ingredient belongs_to :ingredient

There are many more attributes each of these could have, and I might expand on that in a future blog post to illustrate other concepts, but we are keeping it as simple as possible for now.

Getting started

Before we dive in, you can find the completed code here. I will break out each section into branches and link them as we get to them.

I’m using Rails version 6.1.1 and Ruby version 2.7.2.

First things first, we need to create our Rails application:

rails new pantry
cd pantry
rails db:create
rails webpacker:install

(You may not have to do the webpacker:install step, I did because I didn’t have yarn installed already.)

Now we can view our rails application at localhost:3000.

rails server

Creating the models

First, we will create the Recipe.

rails g model Recipe title

And the migration this generation creates.

class CreateRecipes < ActiveRecord::Migration[6.1]
  def change
    create_table :recipes do |t|
      t.string :title

      t.timestamps
    end
  end
end

Next, let’s create the Ingredient.

rails g model Ingredient name

And the migration this generation creates.

class CreateIngredients < ActiveRecord::Migration[6.1]
  def change
    create_table :ingredients do |t|
      t.string :name

      t.timestamps
    end
  end
end

Last, we create RecipeIngredient.

rails g model RecipeIngredient amount description recipe:belongs_to ingredient:belongs_to

And the migration this generation creates.

class CreateRecipeIngredients < ActiveRecord::Migration[6.1]
  def change
    create_table :recipe_ingredients do |t|
      t.string :amount
      t.string :description
      t.belongs_to :recipe, null: false, foreign_key: true
      t.belongs_to :ingredient, null: false, foreign_key: true

      t.timestamps
    end
  end
end

We are going to add an index to the belongs_to fields.

class CreateRecipeIngredients < ActiveRecord::Migration[6.1]
  def change
    create_table :recipe_ingredients do |t|
      t.string :amount
      t.string :description
      t.belongs_to :recipe, null: false, foreign_key: true
      t.belongs_to :ingredient, null: false, foreign_key: true

      t.timestamps
    end
  end
end

Now, let’s run the migrations!

rails db:migrate

Set up the associations inside of our models.

app/models/recipe.rb

class Recipe < ApplicationRecord
  has_many :recipe_ingredients
  has_many :ingredients, through: :recipe_ingredients
end

app/models/ingredient.rb

class Ingredient < ApplicationRecord
  has_many :recipe_ingredients
  has_many :recipes, through: :recipe_ingredients
end

app/models/recipe_ingredient.rb

class RecipeIngredient < ApplicationRecord
  belongs_to :recipe
  belongs_to :ingredient
end

Let’s import the recipe for Best Ever Grilled Cheese Sandwich into our seeds file.

db/seeds.rb

grilled_cheese = Recipe.create(title: "Grilled Cheese")
butter = Ingredient.create(name: "Butter")
bread = Ingredient.create(name: "Sourdough Bread")
mayonnaise = Ingredient.create(name: "Mayonnaise")
manchego_cheese = Ingredient.create(name: "Manchego Cheese")
onion_powder = Ingredient.create(name: "Onion Powder")
white_cheddar = Ingredient.create(name: "Sharp White Cheddar Cheese")
monterey_jack = Ingredient.create(name: "Monterey Jack Cheese")
gruyere_cheese = Ingredient.create(name: "Gruyere Cheese")
brie_cheese = Ingredient.create(name: "Brie Cheese")

RecipeIngredient.create(recipe: grilled_cheese, ingredient: butter, amount: "6 tbsp", description: "softened, divided")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: bread, amount: "8 slices")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: mayonnaise, amount: "3 tbsp")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: manchego_cheese, amount: "3 tbsp", description: "finely shredded")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: onion_powder, amount: "1/8 tsp")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: white_cheddar, amount: "1/2 cup", description: "shredded")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: gruyere_cheese, amount: "1/2 cup", description: "shredded")
RecipeIngredient.create(recipe: grilled_cheese, ingredient: brie_cheese, amount: "4 oz", description: "rind removed and sliced")

When we run this rails task, it will execute the contents of the db/seeds.rb file.

rails db:seed

We can check out what our data looks like in the rails console.

# In the rails console
± rails c

irb(main):005:0> Recipe.first
  Recipe Load (0.1ms)  SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<Recipe id: 3, title: "Grilled Cheese", created_at: "2021-01-14 02:05:16.753424000 +0000", updated_at: "2021-01-14 02:05:16.753424000 +0000">
irb(main):006:0> Recipe.first.ingredients
  Recipe Load (0.2ms)  SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" ASC LIMIT ?  [["LIMIT", 1]]
  Ingredient Load (0.2ms)  SELECT "ingredients".* FROM "ingredients" INNER JOIN "recipe_ingredients" ON "ingredients"."id" = "recipe_ingredients"."ingredient_id" WHERE "recipe_ingredients"."recipe_id" = ? /* loading for inspect */ LIMIT ?  [["recipe_id", 3], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Ingredient id: 16, name: "Butter", created_at: "2021-01-14 02:05:16.762877000 +0000", updated_at: "2021-01-14 02:05:16.762877000 +0000">, #<Ingredient id: 17, name: "Sourdough Bread", created_at: "2021-01-14 02:05:16.766733000 +0000", updated_at: "2021-01-14 02:05:16.766733000 +0000">, #<Ingredient id: 18, name: "Mayonnaise", created_at: "2021-01-14 02:05:16.770198000 +0000", updated_at: "2021-01-14 02:05:16.770198000 +0000">, #<Ingredient id: 19, name: "Manchego Cheese", created_at: "2021-01-14 02:05:16.773554000 +0000", updated_at: "2021-01-14 02:05:16.773554000 +0000">, #<Ingredient id: 20, name: "Onion Powder", created_at: "2021-01-14 02:05:16.776771000 +0000", updated_at: "2021-01-14 02:05:16.776771000 +0000">, #<Ingredient id: 21, name: "Sharp White Cheddar Cheese", created_at: "2021-01-14 02:05:16.779594000 +0000", updated_at: "2021-01-14 02:05:16.779594000 +0000">, #<Ingredient id: 23, name: "Gruyere Cheese", created_at: "2021-01-14 02:05:16.784709000 +0000", updated_at: "2021-01-14 02:05:16.784709000 +0000">, #<Ingredient id: 24, name: "Brie Cheese", created_at: "2021-01-14 02:05:16.789495000 +0000", updated_at: "2021-01-14 02:05:16.789495000 +0000">]>
irb(main):007:0> Recipe.first.recipe_ingredients
  Recipe Load (0.1ms)  SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" ASC LIMIT ?  [["LIMIT", 1]]
  RecipeIngredient Load (0.2ms)  SELECT "recipe_ingredients".* FROM "recipe_ingredients" WHERE "recipe_ingredients"."recipe_id" = ? /* loading for inspect */ LIMIT ?  [["recipe_id", 3], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<RecipeIngredient id: 5, amount: "6 tbsp", description: "softened, divided", recipe_id: 3, ingredient_id: 16, created_at: "2021-01-14 02:05:16.801631000 +0000", updated_at: "2021-01-14 02:05:16.801631000 +0000">, #<RecipeIngredient id: 6, amount: "8 slices", description: nil, recipe_id: 3, ingredient_id: 17, created_at: "2021-01-14 02:05:16.805122000 +0000", updated_at: "2021-01-14 02:05:16.805122000 +0000">, #<RecipeIngredient id: 7, amount: "3 tbsp", description: nil, recipe_id: 3, ingredient_id: 18, created_at: "2021-01-14 02:05:16.808517000 +0000", updated_at: "2021-01-14 02:05:16.808517000 +0000">, #<RecipeIngredient id: 8, amount: "3 tbsp", description: "finely shredded", recipe_id: 3, ingredient_id: 19, created_at: "2021-01-14 02:05:16.812203000 +0000", updated_at: "2021-01-14 02:05:16.812203000 +0000">, #<RecipeIngredient id: 9, amount: "1/8 tsp", description: nil, recipe_id: 3, ingredient_id: 20, created_at: "2021-01-14 02:05:16.815748000 +0000", updated_at: "2021-01-14 02:05:16.815748000 +0000">, #<RecipeIngredient id: 10, amount: "1/2 cup", description: "shredded", recipe_id: 3, ingredient_id: 21, created_at: "2021-01-14 02:05:16.819594000 +0000", updated_at: "2021-01-14 02:05:16.819594000 +0000">, #<RecipeIngredient id: 11, amount: "1/2 cup", description: "shredded", recipe_id: 3, ingredient_id: 23, created_at: "2021-01-14 02:05:16.825120000 +0000", updated_at: "2021-01-14 02:05:16.825120000 +0000">, #<RecipeIngredient id: 12, amount: "4 oz", description: "rind removed and sliced", recipe_id: 3, ingredient_id: 24, created_at: "2021-01-14 02:05:16.829869000 +0000", updated_at: "2021-01-14 02:05:16.829869000 +0000">]>

Now, let’s create another recipe that uses some of the same ingredients. I’ll be using this Gruyere and White Cheddar Mac and Cheese recipe.

db/seeds.rb

# Creating another recipe that uses some of the same ingredients, switch to find_or_create_by so I don't create duplicate records.
grilled_cheese = Recipe.find_or_create_by(title: "Grilled Cheese")
mac = Recipe.find_or_create_by(title: "Gruyere and White Cheddar Mac and Cheese")


butter = Ingredient.find_or_create_by(name: "Butter")
bread = Ingredient.find_or_create_by(name: "Sourdough Bread")
mayonnaise = Ingredient.find_or_create_by(name: "Mayonnaise")
manchego_cheese = Ingredient.find_or_create_by(name: "Manchego Cheese")
onion_powder = Ingredient.find_or_create_by(name: "Onion Powder")
white_cheddar = Ingredient.find_or_create_by(name: "Sharp White Cheddar Cheese")
monterey_jack = Ingredient.find_or_create_by(name: "Monterey Jack Cheese")
gruyere_cheese = Ingredient.find_or_create_by(name: "Gruyere Cheese")
brie_cheese = Ingredient.find_or_create_by(name: "Brie Cheese")
elbows = Ingredient.find_or_create_by(name: "Elbow Macaroni")
breadcrumbs = Ingredient.find_or_create_by(name: "Seasoned Breadcrumbs")
flour = Ingredient.find_or_create_by(name: "Flour")
milk = Ingredient.find_or_create_by(name: "Milk")
half_and_half = Ingredient.find_or_create_by(name: "Half and Half")
nutmeg = Ingredient.find_or_create_by(name: "Nutmeg")
salt = Ingredient.find_or_create_by(name: "Salt")
pepper = Ingredient.find_or_create_by(name: "Pepper")
parmigiano = Ingredient.find_or_create_by(name: "Parmigiano Reggiano")
olive_oil = Ingredient.find_or_create_by(name: "Olive Oil")

RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: butter, amount: "6 tbsp", description: "softened, divided")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: bread, amount: "8 slices")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: mayonnaise, amount: "3 tbsp")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: manchego_cheese, amount: "3 tbsp", description: "finely shredded")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: onion_powder, amount: "1/8 tsp")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: white_cheddar, amount: "1/2 cup", description: "shredded")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: gruyere_cheese, amount: "1/2 cup", description: "shredded")
RecipeIngredient.find_or_create_by(recipe: grilled_cheese, ingredient: brie_cheese, amount: "4 oz", description: "rind removed and sliced")

RecipeIngredient.find_or_create_by(recipe: mac, ingredient: elbows, amount: "1 lb")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: gruyere_cheese, amount: "1 lb", description: "grated")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: white_cheddar, amount: "1 lb", description: "grated")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: breadcrumbs, amount: "1/3 cup")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: butter, amount: "1/2 stick")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: flour, amount: "1/4 cup")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: milk, amount: "3 cup")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: half_and_half, amount: "1 cup")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: nutmeg, amount: "pinch")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: salt, description: "to taste")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: pepper, description: "to taste")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: parmigiano, description: "to taste")
RecipeIngredient.find_or_create_by(recipe: mac, ingredient: olive_oil)

We can jump back into the rails console and see our new data.

± rails c

irb(main):001:0> Recipe.first
=> #<Recipe id: 3, title: "Grilled Cheese", created_at: "2021-01-14 02:05:16.753424000 +0000", updated_at: "2021-01-14 02:05:16.753424000 +0000">
irb(main):002:0> Recipe.last
=> #<Recipe id: 4, title: "Gruyere and White Cheddar Mac and Cheese", created_at: "2021-01-14 02:15:40.205860000 +0000", updated_at: "2021-01-14 02:15:40.205860000 +0000">
irb(main):003:0> Ingredient.count
=> 19
irb(main):004:0> RecipeIngredient.count
=> 21

See the code at this point

Creating controllers

We’re going to create our controller. We won’t have a way to make standalone ingredients, so we only need a recipe controller.

rails g controller recipes

Let’s setup our routes now.

config/routes.rb

Rails.application.routes.draw do
  resources :recipes
end

We can see what using the resources method in our routes provided for us.

± rails routes | grep recipes

recipes     GET    /recipes(.:format)                recipes#index
            POST   /recipes(.:format)                recipes#create
new_recipe  GET    /recipes/new(.:format)            recipes#new
edit_recipe GET    /recipes/:id/edit(.:format)       recipes#edit
recipe      GET    /recipes/:id(.:format)            recipes#show
            PATCH  /recipes/:id(.:format)            recipes#update
            PUT    /recipes/:id(.:format)            recipes#update
            DELETE /recipes/:id(.:format)            recipes#destroy

Let’s flesh out our controller.

First, let’s start up the Rails server.

rails s

Go to localhost:3000/recipes in your browser. You should see an error. Let’s fix that.

app/controllers/recipes_controller.rb

class RecipesController < ApplicationController

  def index
    @recipes = Recipe.all
  end
end

app/views/recipes/index.html.erb

<h1>Recipes</h1>

<% @recipes.each do |recipe| %>
  <h2>
    <%= recipe.title %>
  </h2>

  <ul>
  <% recipe.recipe_ingredients.each do |recipe_ingredient| %>
    <li>
      <%= recipe_ingredient.amount %> <%= recipe_ingredient.ingredient.name %>, <%= recipe_ingredient.description %>
    </li>
  <% end %>
  </ul>
<% end %>

Now refresh to see our index. We will see our listing of the recipes we created.

app/controllers/recipes_controller.rb

class RecipesController < ApplicationController

  def index
    @recipes = Recipe.all
  end

  def show
    @recipe = Recipe.find(params[:id])
  end
end

app/views/recipes/show.html.erb

<h1><%= @recipe.title %></h1>

<ul>
  <% @recipe.recipe_ingredients.each do |recipe_ingredient| %>
    <li>
      <%= recipe_ingredient.amount %> <%= recipe_ingredient.ingredient.name %>, <%= recipe_ingredient.description %>
    </li>
  <% end %>
</ul>

<%= link_to "Back to all recipes", recipes_path %>

Visit this page by going to localhost:3000/recipes/RECIPE_ID_HERE, find the recipe id in your console, Recipe.first.id, etc.

app/controllers/recipes_controller.rb

class RecipesController < ApplicationController

  def index
    @recipes = Recipe.all
  end

  def show
    @recipe = Recipe.find(params[:id])
  end

  def new
    @recipe = Recipe.new
  end
end

app/views/recipes/new.html.erb

<h1>Create a new recipe</h1>

<%= form_with model: @recipe do |form| %>
  <%= form.text_field :title, placeholder: "Recipe Title" %>
  <%= form.submit %>
<% end %>

Visit this page at localhost:3000/recipes/new. If you try and create a recipe, you will get an error because our create action isn’t defined yet.

app/controllers/recipes_controller.rb

class RecipesController < ApplicationController

  def index
    @recipes = Recipe.all
  end

  def show
    @recipe = Recipe.find(params[:id])
  end

  def new
    @recipe = Recipe.new
  end

  def create
    @recipe = Recipe.create(recipe_params)

    if @recipe.save
      redirect_to @recipe
    else
      render action: "new"
    end
  end

  protected
  def recipe_params
    params.require(:recipe).permit(:title)
  end
end

Refresh the new page and create a recipe. You’ll see that we’re able to save a recipe without a title. Let’s add a validation to ensure every recipe at least has a title.

app/models/recipe.rb

class Recipe < ApplicationRecord
  has_many :recipe_ingredients
  has_many :ingredients, through: :recipe_ingredients

  validates_presence_of :title
end

Now we get kicked back to the new page. We’re not going to worry about flash messages or anything at this time. We might find we need them later, though.

Let’s add our edit action now.

app/controllers/recipes_controller.rb

class RecipesController < ApplicationController

  def index
    @recipes = Recipe.all
  end

  def show
    @recipe = Recipe.find(params[:id])
  end

  def new
    @recipe = Recipe.new
  end

  def create
    @recipe = Recipe.create(recipe_params)

    if @recipe.save
      redirect_to @recipe
    else
      render action: "new"
    end
  end

  def edit
    @recipe = Recipe.find(params[:id])
  end

  protected
  def recipe_params
    params.require(:recipe).permit(:title)
  end
end

app/views/recipes/edit.html.erb

<h1>Edit <%= @recipe.title %></h1>

<%= form_with model: @recipe do |form| %>
  <%= form.text_field :title, placeholder: "Recipe Title" %>
  <%= form.submit %>
<% end %>

Visit the edit page of a recipe with localhost:3000/recipes/RECIPE_ID/edit. Once again, we’ll see that we can’t edit a recipe because the update action doesn’t exist yet.

app/controllers/recipes_controller.rb

class RecipesController < ApplicationController

  def index
    @recipes = Recipe.all
  end

  def show
    @recipe = Recipe.find(params[:id])
  end

  def new
    @recipe = Recipe.new
  end

  def create
    @recipe = Recipe.create(recipe_params)

    if @recipe.save
      redirect_to @recipe
    else
      render action: "new"
    end
  end

  def edit
    @recipe = Recipe.find(params[:id])
  end

  def update
    @recipe = Recipe.find(params[:id])

    if @recipe.update(recipe_params)
      redirect_to @recipe
    else
      render action: "edit"
    end
  end

  protected
  def recipe_params
    params.require(:recipe).permit(:title)
  end
end

Now try and edit a recipe.

Lastly, let’s add an action to destroy a recipe, we won’t go over deleting recipes in this tutorial, but we’ve added it to our controller for completion’s sake. At the end of this tutorial, I have ideas to further this application. Adding deletion functionality is one enhancement you could add.

class RecipesController < ApplicationController

  def index
    @recipes = Recipe.all
  end

  def show
    @recipe = Recipe.find(params[:id])
  end

  def new
    @recipe = Recipe.new
  end

  def create
    @recipe = Recipe.create(recipe_params)

    if @recipe.save
      redirect_to @recipe
    else
      render action: "new"
    end
  end

  def edit
    @recipe = Recipe.find(params[:id])
  end

  def update
    @recipe = Recipe.find(params[:id])

    if @recipe.update(recipe_params)
      redirect_to @recipe
    else
      render action: "edit"
    end
  end

  def destroy
    @recipe = Recipe.find(params[:id])
    @recipe.destroy

    redirect_to recipes_url
  end

  protected
  def recipe_params
    params.require(:recipe).permit(:title)
  end
end

In our recipes form, we’re only inputting the title of the recipe. In the next section, we will look into how to create a nested form to add ingredients to a recipe.

View all the code up to this point

Rendering nested forms

We want to create recipe_ingredients when we make a recipe. We do that by using the fields_for that Rails form builder provides. For now, we will only include one field on the recipe_ingredient fields to keep this focused.

app/views/recipes/new.html.erb

<h1>Create a new recipe</h1>

<%= form_with model: @recipe do |recipe_form| %>
  <%= recipe_form.text_field :title, placeholder: "Recipe Title" %>
  <h2>Ingredients</h2>
  <%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
    <%= recipe_ingredient_form.text_field :amount, placeholder: "Amount" %>
  <% end %>

  <br />
  <%= recipe_form.submit %>
<% end %>

Now go to the new recipe page and create a recipe with a recipe_ingredient amount.

Let’s go into the rails console and see what we just created.

± rails c
irb(main):001:0> Recipe.last
   (2.0ms)  SELECT sqlite_version(*)
  Recipe Load (0.1ms)  SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<Recipe id: 7, title: "Cheesy Broccoli", created_at: "2021-01-14 15:26:17.638397000 +0000", updated_at: "2021-01-14 15:26:17.638397000 +0000">
irb(main):002:0> Recipe.last.recipe_ingredients
  Recipe Load (0.1ms)  SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" DESC LIMIT ?  [["LIMIT", 1]]
  RecipeIngredient Load (0.4ms)  SELECT "recipe_ingredients".* FROM "recipe_ingredients" WHERE "recipe_ingredients"."recipe_id" = ? /* loading for inspect */ LIMIT ?  [["recipe_id", 7], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy []>

Our recipe has no recipe ingredients associated with it. Hmmm 🤔. Let’s take a look at the logs when we submitted the form.

Started POST "/recipes" for 127.0.0.1 at 2021-01-14 10:26:17 -0500
Processing by RecipesController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "recipe"=>{"title"=>"Cheesy Broccoli", "recipe_ingredients"=>{"amount"=>"1 cup"}}, "commit"=>"Create Recipe"}
  Unpermitted parameter: :recipe_ingredients

Aha! There’s the culprit. We tried to pass a parameter that our recipe_params method doesn’t know about, recipe_ingredients. Let’s remedy that.

Nested Attributes

Rails nested attributes have this naming convention. We append _attributes to the end of the collection name. We won’t go into why/how it works, be aware that even though the parameters we saw above showed recipe_ingredients, we need to name them the way rails expect inside our recipe_params.

app/controllers/recipes_controller.rb

def recipe_params
  params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount])
end

Okay, should we working now! Let’s try and save a new recipe and check out the logs.

Started POST "/recipes" for 127.0.0.1 at 2021-01-14 10:31:26 -0500
Processing by RecipesController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "recipe"=>{"title"=>"Spaghetti", "recipe_ingredients"=>{"amount"=>"28 oz"}}, "commit"=>"Create Recipe"}
Unpermitted parameter: :recipe_ingredients

Still unpermitted! Because we have to tell the Recipe model to accept the nested attributes for recipe_ingredients (this accepts_nested_attributes is where Rails creates the #{model_name}_attributes method that it’s using inside of the recipe_params.)

app/models/recipe.rb

class Recipe < ApplicationRecord
  has_many :recipe_ingredients
  has_many :ingredients, through: :recipe_ingredients

  validates_presence_of :title

  accepts_nested_attributes_for :recipe_ingredients
end

When we refresh the new page, we’ll find that no field shows up on our form for recipe_ingredients. That’s because we’ve got to build our first recipe_ingredient inside of the new action.

app/controllers/recipes_controller.rb

def new
  @recipe = Recipe.new
  @recipe.recipe_ingredients.build
end

Finally! We’ve got the recipe ingredient field showing. We’re accepting nested attributes. Let’s try to create a recipe with a recipe_ingredient on it.

Still not creating, and now the form is re-rendering new. At this point, we need to add flash messages so we can see errors in the form.

Adding flash notices

Inside of our controller, we’re going to use the standard Rails way of creating and rendering flash messages for our create action.

app/controllers/recipes_controller.rb

def create
  @recipe = Recipe.new(recipe_params)
  respond_to do |format|
    if @recipe.save
      format.html { redirect_to @recipe, notice: "Recipe was successfully created."}
      format.json { render :show, status: :created, location: @recipe }
    else
      format.html { render :new }
      format.json { render json: @recipe.errors, status: :unproccessable_entity }
    end
  end
end

Below is the standard Rails way of rendering errors in a form.

app/views/recipes/new.html.erb

<h1>Create a new recipe</h1>

<%= form_with model: @recipe do |recipe_form| %>
  <% if @recipe.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2>
      <ul>
      <% @recipe.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <%= recipe_form.text_field :title, placeholder: "Recipe Title" %>
  <h2>Ingredients</h2>
  <%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
    <%= recipe_ingredient_form.text_field :amount, placeholder: "Amount" %>
  <% end %>

  <br />
  <%= recipe_form.submit %>
<% end %>

When we try and create our recipe again, we can see the error: Recipe ingredients, ingredient must exist. Okay, let’s make an ingredient when we create a recipe_ingredient.

app/views/recipes/new.html.erb

<h1>Create a new recipe</h1>

<%= form_with model: @recipe do |recipe_form| %>
  <% if @recipe.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2>
      <ul>
      <% @recipe.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <%= recipe_form.text_field :title, placeholder: "Recipe Title" %>
  <h2>Ingredients</h2>
  <%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
    <%= recipe_ingredient_form.text_field :amount, placeholder: "Amount" %>
    <%= recipe_ingredient_form.fields_for :ingredient do |ingredient_form| %>
      <%= ingredient_form.text_field :name %>
    <% end %>
  <% end %>

  <br />
  <%= recipe_form.submit %>
<% end %>

We’ve added a nested fields_for inside of our recipe_ingredients fields_for, this time for an ingredient, and it’s got one field, a text field for its name. Let’s create a recipe and view the logs and see what’s happening.

Started POST "/recipes" for 127.0.0.1 at 2021-01-14 10:51:28 -0500
Processing by RecipesController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "recipe"=>{"title"=>"Macaroni", "recipe_ingredients_attributes"=>{"0"=>{"amount"=>"1lb", "ingredient"=>{"name"=>"Elbow Noodles"}}}}, "commit"=>"Create Recipe"}
Unpermitted parameter: :ingredient

Alright, this is the same thing we saw above with recipe_ingredients. We know exactly how to tackle this.

app/controllers/recipes_controller.rb

def recipe_params
  params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount, ingredient_attributes: [:name]])
end

We’re still getting the same error:

1 error prohibited this recipe from being saved:

    Recipe ingredients ingredient must exist

We still need to make sure that recipe_ingredients accept the ingredients nested attributes. Look at our recipe_params, ingredients_attributes is nested inside recipe_ingredients_attributes.

app/models/recipe_ingredient.rb

class RecipeIngredient < ApplicationRecord
  belongs_to :recipe
  belongs_to :ingredient

  accepts_nested_attributes_for :ingredient
end

Like before, when we added the accepts_nested_attributes_for :recipe_ingredients to Recipe, the form for the ingredient isn’t showing up. We need to build the initial recipe_ingredient in our controller.

app/controllers/recipes_controller.rb

def new
  @recipe = Recipe.new
  @recipe.recipe_ingredients.build.build_ingredient
end

Now we can save our recipe with recipe ingredients!

Adding more recipe ingredients to a recipe

Okay, but no recipe requires only one recipe ingredient. How do we add more to the form? You could build them all inside of the controller.

app/controllers/recipes_controller.rb

def new
  @recipe = Recipe.new
  10.times { @recipe.recipe_ingredients.build.build_ingredient }
end

If you view the form, you have ten inputs for recipe_ingredients (I’ve added some line breaks to the form to make them easier to view)

app/views/recipes/new.html.erb

<h1>Create a new recipe</h1>

<%= form_with model: @recipe do |recipe_form| %>
  <% if @recipe.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2>
      <ul>
      <% @recipe.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <%= recipe_form.text_field :title, placeholder: "Recipe Title" %>
  <h2>Ingredients</h2>
  <%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
    <%= recipe_ingredient_form.text_field :amount, placeholder: "Amount" %>
    <%= recipe_ingredient_form.fields_for :ingredient do |ingredient_form| %>
      <%= ingredient_form.text_field :name %>
    <% end %>
    <br />
  <% end %>

  <br />
  <%= recipe_form.submit %>
<% end %>

But, who can say that every recipe will have precisely ten ingredients? For some applications, this could be enough. Maybe we have an application where there is always an expected number of associations, and it never waivers from that. But for this application, we need this to be dynamic. We want to be able to add and remove recipe ingredients as required. So, let’s get into it.

View the code up to this point

Adding and Removing Recipe Ingredients using Cocoon

To add and remove recipe ingredients, we’re going to use my favorite gem for handling this. The gem is called cocoon. Cocoon defines two helper methods, link_to_add_association and link_to_remove_association, these are aptly named because what they do is provide links that, when clicked, will either build a new association and create the appropriate fields or will create a link with the ability to mark an association for deletion.

Setting up cocoon

To set up cocoon, we first need to add it to our Gemfile. Stop the rails server and add the gem to your Gemfile.

Gemfile

gem 'cocoon'

Next, run bundle install to install the gem. Because we’re using Rails 6, we also need to add the companion file for webpacker. Cocoon requires jQuery, so we need to install that as well.

yarn add jquery @nathanvda/cocoon

Next, let’s add it to our application JavaScript.

app/javascripts/packs/application.js

require("jquery")
require("@nathanvda/cocoon")

Cocoon requires the use of partials. We won’t get into how cocoon works, but these partials named expectedly are how cocoon knows what to render when we click ‘add ingredient.’

app/views/recipes/new.html.erb

<h2>Ingredients</h2>
<div id="recipeIngredients">
  <%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
    <%= render "recipe_ingredient_fields", f: recipe_ingredient_form %>
  <% end %>
  <div class='links'>
    <%= link_to_add_association 'add ingredient', recipe_form, :recipe_ingredients %>
  </div>
</div>

Now when we refresh we see this error: Missing partial recipes/_recipe_ingredient_fields, application/_recipe_ingredient_fields with {:locale=>[:en], :formats=>[:html], :variants=>[], :handlers=>[:raw, :erb, :html, :builder, :ruby, :jbuilder]}. Searched in:

Let’s create our partial.

app/views/recipes/_recipe_ingredient_fields.html.erb

<div class="nested-fields">
  <div>
    <%= f.label :amount %>
    <%= f.text_field :amount %>
  </div>

  <div class="ingredients">
    <%= f.fields_for :ingredient do |ingredient| %>
      <%= render "ingredient_fields", f: ingredient %>
    <% end %>
  </div>

  <div>
    <%= f.label :description %>
    <%= f.text_field :description %>
  </div>
</div>

And let’s add our ingredient_fields partial as well.

app/views/recipes/_ingredient_fields.html.erb

<div>
  <%= f.label :name %>
  <%= f.text_field :name %>
</div>

We are also ready to remove the explicit building of recipe_ingredients in the new action in RecipesController.

Now, we have a form that has a link to add ingredient. However, nothing is happening when we click it. If I open up the dev tools, I see this message in the console:

Uncaught ReferenceError: jQuery is not defined

A quick Google search leads me to this page, and we still have a bit more configuration to do before jQuery is available in our application. Copy the code below into config/webpack/environment.js to define jQuery

config/webpack/environment.js

const { environment } = require('@rails/webpacker')

const webpack = require('webpack')
environment.plugins.prepend('Provide',
  new webpack.ProvidePlugin({
    $: 'jquery/src/jquery',
    jQuery: 'jquery/src/jquery'
  })
)

module.exports = environment

When we click add ingredient, a recipe ingredient form is available for us to fill in! How cool is that? However, the ingredient portion of the form doesn’t exist, so it’s not displaying. Let’s figure out how to fix that.

Cocoon’s link_to_add_association takes four parameters, the parameter html_options has some special options that we can take advantage of. We’re going to look at the wrap_object option. The wrap_object option is a proc that wraps the object. We can use this to build our ingredient when we use the add ingredient link.

Replace the link_to_add_association with this updated version:

<%= link_to_add_association 'add ingredient', recipe_form, :recipe_ingredients, wrap_object: Proc.new { |recipe_ingredient| recipe_ingredient.build_ingredient; recipe_ingredient } %>

When we click add ingredient, the entire form, including the ingredient name, is available to fill in. When filling out my recipe and saving it, I noticed that the description for the recipe_ingredient didn’t seem to be saving. Fix that by adding description to our recipe_params in the RecipeController

def recipe_params
  params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount, :description, ingredient_attributes: [:name]])
end

Let’s add the ability to remove a recipe ingredient now.

app/views/recipes/_recipe_ingredient_fields.html.erb

<div class="nested-fields">
  <div>
    <%= f.label :amount %>
    <%= f.text_field :amount %>
  </div>

  <div class="ingredients">
    <%= f.fields_for :ingredient do |ingredient| %>
      <%= render "ingredient_fields", f: ingredient %>
    <% end %>
  </div>

  <div>
    <%= f.label :description %>
    <%= f.text_field :description %>
  </div>

  <%= link_to_remove_association "remove ingredient", f %>
</div>

Now we can add and also remove our recipe ingredients! Awesome.

However, these changes only apply to the new form. We want to be able to edit recipes and have the same functionality. Let’s do some refactoring and make that happen.

View the code at this point

Refactoring our form and updating recipes

We can pretty much reuse the same form for edit and new, so let’s do that now. First, let’s pull out the form and put it into a partial that we can render inside of the new view.

app/views/recipes/_form.html.erb

<%= form_with model: @recipe do |recipe_form| %>
  <% if @recipe.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2>
      <ul>
      <% @recipe.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= recipe_form.label :title %>
    <%= recipe_form.text_field :title %>
  </div>

  <h2>Ingredients</h2>
  <div id="recipeIngredients">
    <%= recipe_form.fields_for :recipe_ingredients do |recipe_ingredient_form| %>
      <%= render "recipe_ingredient_fields", f: recipe_ingredient_form %>
    <% end %>
    <div class='links'>
      <%= link_to_add_association 'add ingredient', recipe_form, :recipe_ingredients, wrap_object: Proc.new { |recipe_ingredient| recipe_ingredient.build_ingredient; recipe_ingredient } %>
    </div>
  </div>

  <br />
  <%= recipe_form.submit %>
<% end %>

Now, our new view is much cleaner.

app/views/recipes/new.html.erb

<h1>Create a new recipe</h1>
<%= render "form" %>

Let’s go ahead and do the same to our edit view.

app/views/recipes/edit.html.erb

<h1>Edit <%= @recipe.title %></h1>

<%= render "form" %>

Now our edit form allows us to add and remove recipe ingredients. While using the application, I noticed that we don’t have a good way of viewing our recipes individually or getting to an edit page with a click. Let’s add that to access an edit page to test out our functionality quickly.

Change the index page to be a listing of links to the recipes. We don’t need to see the full recipe on the index page.

app/views/recipes/index.html.erb

<h1>Recipes</h1>

<% @recipes.each do |recipe| %>
  <h2>
    <%= link_to recipe.title, recipe %>
  </h2>
<% end %>

Now, on the show page, we can add a link to edit.

app/views/recipes/show.html.erb

<h1><%= @recipe.title %></h1>

<ul>
  <% @recipe.recipe_ingredients.each do |recipe_ingredient| %>
    <li>
      <%= recipe_ingredient.amount %> <%= recipe_ingredient.ingredient.name %>, <%= recipe_ingredient.description %>
    </li>
  <% end %>
</ul>

<%= link_to "Edit recipe", edit_recipe_path(@recipe) %> | <%= link_to "Back to all recipes", recipes_path %>

Now we can quickly get to our edit page. Let’s ensure that things are working as expected. Try adding a new ingredient to a recipe and removing an existing ingredient. When checking to make sure that recipe ingredients get appropriately removed, I saw that they were not. Let’s fix that.

Make sure recipe ingredients get removed

We need to make sure that _destroy, and id are permitted parameters in recipe_params.

def recipe_params
  params.require(:recipe).permit(:title, recipe_ingredients_attributes: [:amount, :description, :_destroy, :id, ingredient_attributes: [:name, :id]])
end

We also need to add allow_destroy: true to our accepts_nested_attributes. Now we can add and remove recipe ingredients successfully.

app/models/recipe.rb

accepts_nested_attributes_for :recipe_ingredients, allow_destroy: true

Let’s make sure that we are taking advantage of the fact that we have a has_many :through. When it comes to ingredients, we want the name field to be unique. If we have an ingredient with a name of butter, every recipe named butter will use that ingredient instead of creating multiple ingredients of the same name. Down the road, we will be able to find all the recipes that have a particular ingredient.

Create or find ingredients

To our Recipe model, let’s add a before_save hook so we can get at our ingredients before they get saved. We can use find_or_create_by when assigning an Ingredient to RecipeIngredient.

app/models/recipe.rb

class Recipe < ApplicationRecord
  has_many :recipe_ingredients
  has_many :ingredients, through: :recipe_ingredients

  validates_presence_of :title

  accepts_nested_attributes_for :recipe_ingredients, allow_destroy: true

  before_save :find_or_create_ingredients

  def find_or_create_ingredients
    self.recipe_ingredients.each do |recipe_ingredient|
      recipe_ingredient.ingredient = Ingredient.find_or_create_by(name:recipe_ingredient.ingredient.name)
    end
  end
end

Test it out by adding multiple recipe ingredients with the same ingredient name, and then go into the rails console, and we can see that an ingredient with the same name gets created once. I made a recipe called “Butter Two Ways,” both recipe_ingredients have the name of butter. When I go into the rails console, I see that there is only one ingredient named butter and that both of my recipe_ingredients belong to it.

rails c

View your last Recipe in the rails console after you create it in the application.

recipe = Recipe.last
  Recipe Load (0.1ms)  SELECT "recipes".* FROM "recipes" ORDER BY "recipes"."id" DESC LIMIT ?  [["LIMIT", 1]]
=> #<Recipe id: 18, title: "Butter Two Ways", created_at: "2021-01-17 00:47:28.243659000 +0000", updated_at: "2021-01-17 00:47:28.243659000 +0000">

recipe.recipe_ingredients.count
   (0.2ms)  SELECT COUNT(*) FROM "recipe_ingredients" WHERE "recipe_ingredients"."recipe_id" = ?  [["recipe_id", 18]]
=> 2

recipe.recipe_ingredients.pluck(:ingredient_id)
   (0.1ms)  SELECT "recipe_ingredients"."ingredient_id" FROM "recipe_ingredients" WHERE "recipe_ingredients"."recipe_id" = ?  [["recipe_id", 18]]
=> [180, 180]

Ingredient.find(180)
  Ingredient Load (1.0ms)  SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."id" = ? LIMIT ?  [["id", 180], ["LIMIT", 1]]
=> #<Ingredient id: 180, name: "butter", created_at: "2021-01-16 23:17:33.293763000 +0000", updated_at: "2021-01-16 23:17:33.293763000 +0000">

Ta-da 🎉 Wow, we did it y’all! How does that feel?

View the code up to this point

Wrap Up

Phew, that was a doozy of a post. I hope it was informative and useful for you. Let’s refresh what we did.

  1. We learned what a has_many :through relationship is. It’s a relationship that links two other models together. A recipe has many ingredients through its recipe ingredients. In practice, it creates a third database table that has at least a reference to both tables to which it belongs.
  2. We learned when we need to use a has_many :through relationship, we use one because we wanted ingredients on our recipes to have additional information that was specific to the recipe only. Reach for a has_many :through when you need other data on the association.
  3. We defined the Pantry app and its requirements
  4. We built a Rails 6 application, using generators for our models, migrations, and controllers. Then we used a seed file to populate our database initially and the rails console to view our data. We also built out a CRUD controller for recipes and a form to input our recipe information into and save it.
  5. We created nested forms using fields_for and accepts_nested_attributes_for, learned how to build out our ingredient so the ingredient fields would render
  6. We added what we needed when we needed it, like when we added our flash notices when we needed more information about why our recipe wasn’t saving.
  7. We used the cocoon gem to make our nested form dynamic, allowing us to add and remove recipe ingredients
  8. We pulled form partials out so we could reuse our form on the new and edit page
  9. We used an ActiveRecord lifecycle hook (before_save) so that recipe_ingredients would either use an existing ingredient if it existed and, if not, create the ingredient.

Don’t you feel accomplished?

I have some further challenges for you if you’d like to iterate more on this application. Add auto-completion to the ingredient name field, selecting an option from the auto-select dropdown could populate a hidden id field in the ingredient fields. Add specs. Ideally, we would do this before/alongside writing our code, but I wanted to focus on the code, still trying to figure out this balance. Add a way to use the destroy action from the RecipesController. Make the index page better, add a link to create new recipes. Add a way to search for recipes that include a specific ingredient. Think about how your controller would change if your Rails application were API only. How would the request payload look? What changes would you need? There are so many cool ways to expand on this application. I hope that this provides a good base and jumping-off point if you’re so inclined.

I do enjoy writing these tutorials, but they take a lot of effort! If you appreciated this post, please share it on Twitter or other social media; that would make my day. Keep in mind that there are different ways to solve this problem; this happens to be my approach using my favorite tools. If you have any questions or comments, please tweet at me, I always appreciate lovely messages and constructive feedback.