LaunchKit
← All posts
· 5 min read · by The LaunchKit team · 1 views

accepts_nested_attributes_for, and the children it throws away

A form that creates a survey and its questions in one post is the standard case for accepts_nested_attributes_for, and the standard case works. What's worth knowing before you use it is what it does when the input isn't perfect, because two of its defaults fail quietly and one of them loses data.

Measured on Rails 8.1.4.

What the line actually gives you

class Survey < ApplicationRecord
  has_many :questions, dependent: :destroy
  accepts_nested_attributes_for :questions, allow_destroy: true, reject_if: :all_blank
end

That defines questions_attributes=, and that single writer is the whole mechanism. Your form posts survey[questions_attributes][0][body], strong parameters permits it, Survey.new assigns it, and the children come along on save.

The options are stored where you can read them, which is handy when you inherit a model and want to know what it's set to:

Survey.nested_attributes_options[:questions]
# => {allow_destroy: true, update_only: false, reject_if: #<Proc:... nested_attributes.rb:303>}

:all_blank isn't a symbol Rails checks for later, it's resolved to a lambda at declaration time.

The query cost is as good as it looks. Creating a survey with three questions sent 4 INSERTs and nothing else: one per row, no lookups, four queries in total.

The option that eats your data

reject_if: :all_blank exists to solve a real problem. A nested form renders empty rows for new children, and without this option an untouched empty row becomes a validation error on a child the user never filled in.

What it does in exchange:

survey = Survey.create!(title: "s", questions_attributes: [{ body: "a" }, { body: "" }, { body: nil }])
survey.questions.count   # => 1

Three submitted, one saved. No exception, no validation error, nothing on survey.errors. The other two were dropped before any validation ran, and nothing anywhere records that it happened.

It also treats whitespace as blank:

survey = Survey.new(title: "s", questions_attributes: [{ body: "ok" }, { body: " " }])
survey.valid?          # => true
survey.errors.first    # => nil

A user typed a space into a row, the form posted it, and that row is gone. They get a success page, and the thing they typed isn't there. That's a support ticket nobody can reproduce, because the space doesn't survive into anything you can look at afterwards.

The honest fix depends on which problem you actually have. If the empty rows come from your own form rendering blanks, build them in the view instead and drop the option. If users genuinely submit partial rows, a custom reject_if lambda that only skips rows where every field is nil is narrower than :all_blank and won't eat a stray space.

A nested form posting three children through accepts_nested_attributes_for with reject_if all_blank. The first child has a body and is saved. The second has an empty string and the third is nil, and both are dropped before any validation runs, so nothing appears in the parent's errors and the save reports success. A fourth case shows a child whose only content is a single space, which is also treated as blank and discarded while the parent reports valid with an empty errors array. A separate panel shows a child id belonging to a different parent being passed in, and Rails raising ActiveRecord::RecordNotFound because the lookup is scoped to the association.

_destroy, and the option it needs

survey.update!(questions_attributes: [{ id: question.id, _destroy: "1" }])

The child is gone, and the key is magic: _destroy is checked by name, and its value goes through Rails' boolean cast, so "1", "true" and true all work while "0" and "false" don't.

Without allow_destroy: true the key is ignored entirely. Not an error, not a warning, just a form with a delete checkbox that does nothing, which is a satisfying bug to find in a code review and a miserable one to find in production.

The attack that doesn't work

The obvious worry about a nested form is the id field: it's in the markup, a user can change it, and it decides which record gets updated. So what happens when somebody posts a child id that belongs to somebody else's parent?

mine.update!(questions_attributes: [{ id: someone_elses_question.id, body: "hacked" }])
# ActiveRecord::RecordNotFound

Rails looks the child up through the association rather than globally, so an id outside that parent's children simply isn't found. The record is untouched and your controller gets an exception it can render as a 404.

That's the right default and it's worth knowing you have it, because the usual advice is to be nervous about nested ids without ever saying what Rails already does. What you still own is permitting :id and :_destroy in strong parameters, and deciding whether a 404 or a redirect is the right answer for your form.

What this page doesn't cover

No view. Everything above is model-level, so fields_for, the child_index trick for adding rows with JavaScript, and the Turbo or Stimulus controller that usually drives that are all out of scope, and they're where most of the real work is.

Nothing here covers update_only for a has_one, limit: on the collection, or what happens when a nested child fails validation while its siblings pass, which is where the error messages get hard to render usefully. Nor the alternative shape, a form object with its own validations, which is the thing to reach for once the nesting goes two levels deep.

#rails #active-record #forms

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.