Rails Remote Forms with Turbolinks and Stimulus

In Rails, forms can be submitted without a page reload by using remote forms. When using a remote form, Rails submits the form trough an ajax request. Any updates to displayed view, have then to be done with Javascript. This can either be automatically handled by Turbolinks or one can manually handle it by listening to the ajax:success and ajax:error events. This post shows both approaches and specifically how to do the manual handling of the events with Stimulus.

But first, let’s get the basics of what is shown in the video working without using a remote form, meaning that there will be a page reload whenever a new message is inserted.

The model used for this example is a Message model which has a column named content to store the actual message. As seen in the video, the index view shows all messages and a form to create a new message. In this first version without a remote form, the following view file is used for that:

1
2
3
4
5
6
7
<h1>Messages</h1>
<%= render @messages %>

<%= form_with(model: Message.new, local: true) do |form| %>
  <%= form.text_area :content %>
  <%= form.submit style: 'display: block' %>
<% end %>

app/views/messages/index.html.erb

Notice the local: true. When using the form_with helper, which is used by the view generator in Rails 5 and 6, forms are submitted remotely by default. To turn this off, local: true has to be set (which by default is also done by the view generator).

With <%= render @messages %> each message is rendered according to the following partial, which simply shows the message’s created_at and content values:

1
2
3
<div>
  <%= message.created_at %>: <%= message.content %>
</div>

app/views/messages/_message.html.erb

And the controller looks as follows:

1
2
3
4
5
6
7
8
9
10
11
class MessagesController < ApplicationController
  def index
    @messages = Message.all
  end

  def create
    @message = Message.new(params.require(:message).permit(:content))
    @message.save!
    redirect_to messages_path
  end
end

app/controllers/messages_controller.rb

When the Create Message Button is pressed, a request is made, leading to the create action being called. This action creates a new message and redirects to messages_path so that the index action is called and the same page, including the new message, is rendered again. So now the functionality is there, but only with a page reload:

When using Turbolinks, removing the page reload is as easy as removing local: true or adding remote: true, in case you are using the form_tag or form_for helper or simple_form_for with the simple_form gem. The form will then be submitted remotely and turbolinks will handle the redirect.

In an example as simple as this one, this is probably the best way, as no further code changes are needed. But for each new message, the HTML for the complete page, including the new message, is sent from the server to the client. For a more complex page, sending just the parts that changed, in this case the new message, might be advantageous.

Remote Forms with Stimulus

With this approach, the controller will only return the rendered partial of the new message:

1
2
3
4
5
6
7
8
9
10
11
class MessagesController < ApplicationController
  def index
    @messages = Message.all
  end

  def create
    @message = Message.new(params.require(:message).permit(:content))
    @message.save!
    render @message
  end
end

app/controllers/messages_controller.rb

Insertion of this partial has to be handled manually, by listening to the ajax:success event. You could just write some jQuery to do this, but I’m using Stimulus to keep everything nice and organized. The view file, when using the Stimulus controller looks as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<h1>Messages</h1>

<div data-controller='message-list'>
  <div data-target='message-list.messages'>
    <%= render @messages %>
  </div>

  <%= form_with(model: Message.new,
        data: { action: 'ajax:success->message-list#append' }
      ) do |form| %>
    <%= form.text_area :content, data: { target: 'message-list.input' } %>
    <%= form.submit style: 'display: block' %>
  <% end %>
</div>

app/views/messages/index.html.erb

In this case, a Stimulus controller named message-list is used. It has a messages target, which holds all the messages, and an input target which is the input field for new messages. When the ajax:success event is fired, the append method of the Stimulus controller is called.

1
2
3
4
5
6
7
8
9
10
11
12
import { Controller } from 'stimulus';

export default class extends Controller {
  static targets = ['input', 'messages'];

  append(event) {
    const [data, status, xhr] = event.detail;
    this.messagesTarget.innerHTML += xhr.response;

    this.inputTarget.value = '';
  }
}

app/javascript/controllers/message_list_controller.js

The append method takes the returned partial of the new message and inserts it after the last message. Then it resets the input.

Handling Errors

To handle errors one can use a second partial to render an error message and listen for the ajax:error event. The updated controller looks as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MessagesController < ApplicationController
  def index
    @messages = Message.all
  end

  def create
    @message = Message.new(params.require(:message).permit(:content))
    if @message.save
      render @message
    else
      render partial: 'error', comment: @comment, status: :bad_request
    end
  end
end

app/controllers/messages_controller.rb

And the view file like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<h1>Messages</h1>

<div data-controller='message-list'>
  <div data-target='message-list.messages'>
    <%= render @messages %>
  </div>

  <%= form_with(model: Message.new,
        data: { action: 'ajax:success->message-list#append 
                         ajax:error->message-list#showError' }
      ) do |form| %>
    <%= form.text_area :content, data: { target: 'message-list.input' } %>
    <%= form.submit style: 'display: block' %>
  <% end %>
</div>

app/views/messages/index.html.erb

The Stimulus controller would then have a showError method, which would handle the insertion of the error partial.

Discussion and feedback