stephencelis

Rails controllers, views & variables

, Chicago

In object-oriented programming, an object uses instance variables to store private information. If you want to access an object’s instance variables, those variables should only be accessible through instance methods. At least, this is the expectation.

So when one dives into Rails, they may find it quite confusing when an instance variable set in a controller is also available in the view. Convenient, sure…but it somehow doesn’t feel true to Ruby. The controller and view are different objects with their own scopes, right?

How hard is it to be a purist? How can we tell Action Controller to be a little more discreet?

# config/initializers/less_gossip.rb

# Stop sharing so much!
ActionController::Base.class_eval do
  def add_instance_variables_to_assigns() end
end

# Stop caring so much!
ActionView::Base.class_eval do
  def assign_variables_from_controller() end
end

Now the controller has some privacy. Rails won’t force it to tell all its secrets to the view. The controller still needs to do its job, though, which likely involves retrieving data and serving it to the view. So how to we mediate the flow of information without instance variables? The same way Ruby usually does: attribute readers.

Let’s say we have a CRUD controller for articles. ArticlesController#index would normally assign @articles, so let’s define a reader method, articles, instead.

# app/controllers/articles_controller.rb
def articles
  Article.all
end

Now we can call articles throughout the controller, and it will return what @articles previously would have. Rails is pretty good about caching database queries, but that doesn’t mean we shouldn’t memoize as a best practice for optimization:

def articles
  @articles ||= Article.all
end

Ah, instance variables used as they were meant to be used.

So what about @article? Doesn’t its value change depending on the action being called? We just have to be a little smarter. Several lines of @article = Article.find(params[:id]) wasn’t very DRY, anyhow.

def article
  @article ||= if params[:id]
    Article.find params[:id]
  else
    Article.new params[:article]
  end
end

There we go. We’re accessing variables through getters! When we’re in the view, we only need to call those methods on the controller, which is available through ActionView::Base#controller. Here’s an example:

<%# app/views/articles/index.html.erb%>
<%= render controller.articles %>

What? That’s pretty ugly? And what about the controller public instance methods? They should refer to actions?

I guess we can’t completely ignore Rails’ conventions. We’ll have to make those getters private. But how, now, can we deliver these methods to the view? Luckily, Rails makes this easy with helpers:

# app/controllers/articles_controller.rb
helper_method :articles, :article

Now it looks nicer in the view, if a bit plainer than the @ decorations we’re all used to:

<%# app/views/articles/index.html.erb%>
<%= render articles %>

There are, actually, a few benefits to all this maneuvering (beyond clarity for the classically-trained Rubyist):

Maintainability
Any before filter you used, in the past, to set instance variables, is now irrelevant. You no longer have to worry about scoping it with :only or :except, or updating it when you add, remove, or rename actions.
Cacheability
These methods will only run when they’re first called during a request. If they only appear in the view, and the view is cached, you won’t hit the database.1

Here’s our completed code:


1 Mike Burrows points out that if ActiveRecord::RecordNotFound is raised in the template, you are given a 500 error in production. ActionController::Rescue normally handles the 404s, while Action View wraps exceptions in its own TemplateError (always 500), so—coupling aside—we just have to make the view more aware.

  # config/initializers/less_gossip.rb
  class ActionView::Template
    def render_template(view, local_assigns = {})
      render(view, local_assigns)
    rescue Exception => e
      raise e unless filename
      case e
      when ActiveRecord::RecordNotFound # Ah. Here we are.
        raise e
      when TemplateError
        e.sub_template_of(filename)
        raise e
      else
        raise TemplateError.new(self, view.assigns, e)
      end
    end
  end
  
Comments powered by Disqus.

Otherwise: