Last week a HasManyThrough association gave me a headache so I’ve decided to write down the solution so I can come back to it, but also share it so other people like me find an answer faster, since it took me like an hour to duck it.

Context

So, in an application I have a many-to-many relationship which I decided to implement using has_many(through:), and while tackling the form for a has_many association is fairly straight forward (IIRC), I couldn’t for the life of me make the nested attributes work out-of-the box with the form_with helper.

Two people visibly confused.

For this example

Let’s picture a simple case: we want a blog where posts can have several categories and we want to manage categories separately for whatever reason.

$ rails g model Post title content:text
$ rails g model Category name
$ rails g model PostCategory post:references category:references

First attempt: accepts_nested_attributes_for + fields_for

Since I wanted to create a PostCategory record, using accepts_nested_attributes_for in conjunction with the fields_for helper made sense to me, since it was actually dealing with a different model, but seeing the logs quickly made me realize that I was doing something wrong:

Second attempt: yada yada

I tried different permutations of accepts_nested_attributes_for, didn’t change much.

Nth attempt: read the has_many docs

After feeling more than annoyed I eventually found an old post saying that has_many associations add <model>_ids & <model>_ids= methods, so I had a look and what do you know? I could skip the attributes joggling and simply add category_ids to the accepted parameters from the controller and voilà! It works:

NOTE: that in the parameters we accept a list.

PostsController < ApplicationController
  # ...

  def create
    @post = Post.new(post_params)

    if @post.save
      redirect_to @post, notice: 'All good!'
    else
      render :new, alert: 'Something went wrong~'
    end
  end

  # ...

  private

  def post_params
    params.require(:post)
      .permit(:title,
              :content,
              category_ids: [])
  end
end

And in your view you can use a native <select multiple> or enhance it with something like select2:

# app/views/posts/_form.html.erb
# ...

<div class="field">
  <%= f.label :category_ids, "Categories" %>
  <%= f.select :category_ids,
    options_from_collection_for_select(categories, :id, :name),
    { include_blank: false },
    multiple: true,
    class: 'select multiple-select',
    size: 5
  %>
</div>

# ...

Bonus: nested attributes using JS

If you’re using JSON then it’s easy, just use the correct keys and nest the entries in an array, if you’re using FormData the naming is a bit more cumbersome:

function json2formData(post) {
  const data = new FormData

  // ...

  for (const cat of json.post_categories) {
    // if this is a new record it won't affect
    data.append(
      'post[post_categories_attributes][][id]',
      cat.id
    )
    data.append(
      'post[post_categories_attributes][][category_id]',
      cat.category_id
    )
    // this is for existing records only, though it doesn't affect if it's new
    data.append(
      'post[post_categories_attributes][][_destroy]',
      cat._destroy === 1 ? 1 : 0
    )
  }

  return data
}

Closing thoughts

As with most things in Rails, the solution is pretty and subtle. When you know it, it feels great; when you don’t, it may feel like a pain in the ass — but if it doesn’t feel right, keep looking!

Or implement your own solution, with blackjack and hookers.


"Confused?" by JoeBenjamin is licensed under CC BY-NC 2.0 .