Not every "one parent, many children" form needs nested attributes. If the parent already exists and children are added, edited, or removed one at a time, Turbo Frames and Streams handle it better than a big nested form, without the usual bookkeeping.

The catch: the parent has to exist first. If you need an atomic parent-plus-children save, see Nested Forms in Rails 8 instead.

The Setup

Each child is its own Rails resource with its own controller. No nested attributes, no fields_for, no special strong params. The models look like any other pair:

# app/models/project.rb
class Project < ApplicationRecord
  has_many :tasks, dependent: :destroy
end

# app/models/task.rb
class Task < ApplicationRecord
  belongs_to :project
  validates :title, presence: true
end

Routes nest the child under the parent:

# config/routes.rb
resources :projects do
  resources :tasks, except: [:index, :show]
end

The Views

The parent view wraps the task list in a Turbo Frame and lazy-loads a blank "new task" form at the bottom. The frame IDs are what the stream responses will target:

-# app/views/projects/show.html.haml
%h1= @project.name

#tasks
  = render @project.tasks

= turbo_frame_tag "new_task", src: new_project_task_path(@project), loading: :lazy

The task partial declares its own frame, keyed on dom_id(task), so inline edits can swap a single row without touching the rest of the list:

-# app/views/tasks/_task.html.haml
= turbo_frame_tag dom_id(task) do
  .task
    %span= task.title
    = link_to "Edit", edit_project_task_path(task.project, task)
    = button_to "Delete", project_task_path(task.project, task), method: :delete

The new and edit views are just one-liners that render the same form partial that we will use in the controller:

-# app/views/tasks/_form.html.haml
= turbo_frame_tag "new_task" do
  = form_with(model: [task.project, task]) do |f|
    = f.text_field :title, placeholder: "New task"
    = f.submit "Add"

The Tasks Controller

This is where the work happens. create, update, and destroy respond with Turbo Streams that patch the DOM directly instead of redirecting. No full-page reloads, no nested form bookkeeping:

# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
  # before_actions, new, edit, set_project, set_task, task_params: all the usual stuff

  def create
    @task = @project.tasks.build(task_params)

    if @task.save
      render turbo_stream: [
        turbo_stream.append("tasks", @task),
        turbo_stream.update("new_task", partial: "form", locals: { task: @project.tasks.build })
      ]
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @task.update(task_params)
      render turbo_stream: turbo_stream.replace(@task)
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @task.destroy
    render turbo_stream: turbo_stream.remove(@task)
  end
end

A successful create appends the new task to the tasks list and replaces the lazy-loaded "new_task" frame with a blank form. Update replaces the single row by its dom_id. Destroy removes it. That's the whole flow.

What You Get

The only thing you give up is the ability to create parent and children in a single transaction. I mean, c'mon. Do you really need that?