Using Rails form objects
Update: User MelissaLiberty from Reddit pointed out how they would improve the form object and some of it faults. This post has been updated to reflect their excellent points.
Often, when we start a new Rails app we start with simple controllers, and we start by generating everything with scaffolding. There is nothing wrong with this and it is a great way to be able to build your basic models and perform CRUD actions on them but it breaks down a bit when the controllers get more complex. For instance, you might be building a Twitter clone where start with a User
who publishes Tweets
.
You start building this app by running:
rails g scaffold User username:string
rails g scaffold Tweet body:string
So now you can create new tweets through the TweetsController
and new users through the UsersController
. The problem is what to do when the boss comes to you and says they want a new user to create their first tweet when they signup?
"This is high priority! We gotta juice the engagement metrics for the investors. The business is on the line! I don't care if it is a crappy hack!" - Your boss
Your first thought might be to edit the UsersController
so that the Tweet is created inline. It might look something like this:
class UsersController < ApplicationController
#....
def create
@user = User.new(user_params)
@tweet = Tweet.new(tweet_params)
if @user.save && @tweet.save
redirect_to @user, notice: 'User and tweet was successfully created.'
else
render :new
end
end
#....
end
It looks pretty much like a normal create action except for the additions of @tweet = Tweet.new(tweet_params)
the && @tweet.save
. Of course, you would have additional changes to the form in the view and you would need to write the tweet_params
method, but I see some bigger problems.
While this controller is still clearly understandable, it will grow with time and it doesn’t account for validation errors on the @tweet
model. It also seems to already be incorrectly named. I would move this process to a new controller and leave the UsersController
alone (or delete the create
action if it will no longer be needed).
You can move the whole sign up process to a SignUpsController
instead. Now you will be describing the process that is actually happening and if (when) the signup process changes in the future you will know where to put the changes. Let’s start with the controller itself, it should look like this:
# app/controllers/sign_ups_controller.rb
class SignUpsController < ApplicationController
def new
@sign_up = SignUp.new
end
def create
@sign_up = SignUp.new(sign_up_params)
if @sign_up.save
redirect_to root_url, notice: 'Sign Up was a Success'
else
render :new
end
end
private
def sign_up_params
params.permit(:username, :first_tweet)
end
end
This is the full controller and it is pretty simple, just like we like them. The new
action renders a view with the @sign_up
instance variable and the create
action put the params on the @sign_up
variable and saves it.
Note: You will also need to add resources :sign_ups
and get "/signup", to: "sign_ups#new"
to config/routes.rb
to make the controller work.
So the magic must be in this SignUp
object right? Let’s look at it.
# app/forms/sign_up.rb
class SignUp
include ActiveModel::Model
attr_accessor :username, :first_tweet
def save
ActiveRecord::Base.transaction do
user = User.create(username: username)
add_errors(user.errors) if user.invalid?
user.save!
tweet = Tweet.create(body: first_tweet, user_id: user.id)
add_errors(tweet.errors) if tweet.invalid?
tweet.save!
end
rescue ActiveRecord::RecordInvalid => exception
return false
end
private
def add_errors(model_errors)
model_errors.each do |attribute, message|
errors.add(attribute, message)
end
end
end
You can see that there isn’t too much that is special about this object but there are a few neat tricks that make it tick. The first is the line include ActiveModel::Model
that is there to make the SignUp
form object quack like an ActiveModel duck. From the Rails API about ActiveModel::Model:
That first, line along with attr_accessor :username, :first_tweet
, is what lets it work with the form.
Active Model Basic Model: "Includes the required interface for an object to interact with Action Pack and Action View, using different Active Model modules. It includes model name introspections, conversions, translations and validations. Besides that, it allows you to initialize the object with a hash of attributes, pretty much like Active Record does."
Now, we have the :username
and :first_tweet
on the form object so it is time to create the underlying objects it is composed of. In a previous version of this blog post, we did this in the initialize but that created the objects on the database without ensuring that they were all valid and could have left us in a state where we created one valid object and didn’t create one invalid object. One out of two objects created isn’t what we are going for so now we are creating the objects in the def save
method.
def save
ActiveRecord::Base.transaction do
@user = User.create(username: username)
add_errors(@user.errors) if @user.invalid?
@user.save!
@tweet = Tweet.create(body: first_tweet, user_id: @user.id)
add_errors(@tweet.errors) if @tweet.invalid?
@tweet.save!
end
rescue ActiveRecord::RecordInvalid => exception
return false
end
We create both of the models that this signup is composed of in the save
method. This gives us the instance variables @user
and @tweet
that we can check to see if they are valid. The @user.invalid?
checks the model to make sure that it passes its own internal validation and if it doesn’t then add_errors(@user.errors)
will add those errors to the form object. As an example, if the User
has a validation for a unique username then we want that user model and the sign_up form to have the validation error on them if the username is not unique.
Furthermore, we have wrapped the @user.save!
and @tweet.save!
in an ActiveRecord::Base.transaction
to ensure that one won’t save without the other.
If anything goes wrong with our transaction or validations then the rescue will return false and let the controller know that the form object did not save.
We have gone through the controller and the form object so let’s look at the view.
# app/views/sign_ups/new.html.erb
<p id="notice"><%= @sign_up.errors.messages if @sign_up.errors.any? ></p>
<h1?Sign Up Here</h1>
<%= form_for @sign_up do |f| %>
<%= label_tag(:username, "username") %>
<%= text_field_tag :username %>
<%= label_tag(:first_tweet, "first_tweet") %>
<%= text_field_tag :first_tweet %>
<%= submit_tag("Create user & first tweet") %>
<% end %>
The first thing to notice is the line where we show off any errors. If our validations added some errors then @sign_up.errors.any?
will now get them to show up on the page after @sign_up.save
fails in the controller and the controller sends the person attempting to sign up back through the render :new
line to the new
view. We didn’t want to forget about showing the user their errors right?
Other than that this appears to be a normal, Rails form_for
that is passed the @sign_up
object. That ability comes from the include ActiveModel::Model
and attr_accessor :username, :first_tweet
lines we set earlier in the SignUp form object. We put in some labels and text_field_tags and a submit button and our form object is complete.