View Objects in Ruby on Rails
By: Ryan Francis / April 20, 2016
Introduction
If you like writing object-oriented code, you will probably like the view object pattern. The basic concept is that a PORO (Plain Old Ruby Object) is used to generate HTML for the view. With all the power of object-oriented programming, view objects can make your life a lot easier when it comes to handling complex logic in the view.
Example: Tabs
Consider the example of creating page tabs like below:
Home Page Tabs:
Settings Page Tabs:
Analyzing these two groups of tabs, we see commonalities and differences.
Traits common to all tabs:
- Each tab group has one active tab
- Each tab links to another page
- Each tab group has the same styling
Traits unique to each tab group:
- The number of tabs may change
- Link paths change for every tab
- The text of each tab is different
- The logic to determine active tabs changes
In order to create a new group of tabs, a developer should only have to write code about the uniquenesses of those tabs. For example, when creating the settings page tabs, we should only have to specify the links (Profile, My Account), where those links point to, and some logic to determine which link is currently active.
However, in my experience the inclination is to just replicate the totality of the tabs HTML for every implementation of tabs throughout the application, like so:
home.html.erb:
<div class="tabs home-tabs"> <%= link_to 'Home', root_path, class: "tabs--link #{'is-active' if is_active_home_tab?(:home)}" %> <%= link_to 'Dashboard', dashboard_path, class: "tabs--link #{'is-active' if is_active_home_tab?(:dashboard)}" %> <% if can?(:manage, @company) %> <%= link_to 'Company Admin', company_path(@company), class: "tabs--link #{'is-active' if is_active_home_tab?(:company)}" %> <% end %> </div>
settings.html.erb:
<div class="tabs settings-tabs"> <%= link_to 'Profile', profile_path(current_user.profile), class: "tabs--link #{'is-active' if is_active_settings_tab?(:profile)}" %> <%= link_to 'My Account', edit_user_registration_path(current_user), class: "tabs--link #{'is-active' if is_active_settings_tab?(:user}" %> </div>
application_helper.rb
def is_active_home_tab?(tab) case tab when :home return true if controller_name == 'home' when :dashboard return true if controller_name == 'dashboards' when :company return true if controller_name == 'companies' end return false end def is_active_settings_tab?(tab) case tab when :profile return true if controller_name == 'profiles' when :user return true if controller_name == 'users' end return false end
There are quite a few problems with this approach. First of all, the HTML structure of our tabs component is duplicated in multiple spots. For example, they must be wrapped in a div with a class tabs and each tab within the group must have the class tabs–link. The knowledge of that structure is in both home.html.erb and settings.html.erb. If we need to modify the structure of the tabs, either to change the styling or behavior, we’ll need to do it in both of these files. This is the very reason why upgrading a framework like Bootstrap is such a pain – there are dependencies to the HTML structure and CSS classes all over the place.
Secondly, the logic about which tabs a user can see based on their permissions is just written with the ERB if statement. While in this example it is not a big deal, if the logic to determine whether or not to show particular tabs was more complex, performing it in the view would get unwieldy.
Finally, the implementation of the “is-active” class is clunky at best, and isn’t really something we want implemented in the application helper since it doesn’t really pertain to every part of the site.
Implementing View Objects
Boilerplate Code
While view objects can be implemented however you choose – they are just plain old ruby classes, afterall – there is boilerplate functionality that every view object should have. Some examples include:- Route helpers- Helper methods like numbertocurrency- CanCanCan methods (if you use the gem)- The view context (for methods like controller_name)
Because of that, I’m going to start by offering some boilerplate code that you can add to your Rails project to be able to start efficiently using view objects.
Let’s start with our base ViewObject class that all view objects inherit from:
# app/view_objects/view_object.rb class ViewObject attr_reader :context include Rails.application.routes.url_helpers include ActionView::Helpers include ActionView::Context # comment these two lines out if not using cancan include CanCan::ControllerAdditions delegate :current_ability, :to => :context def initialize(context, args = {}) @context = context after_init(args) end def after_init(args = {}) # implemented by child classes end end
Now we need to autoload the classes in app/view_objects/
# application.rb config.autoload_paths += Dir["#{config.root}/app/view_objects/**/"]
Note: make sure to restart your server after changing application.rb
Last but not least, let’s add this helper method to application_helper:
# application_helper.rb def view_object(name, args = {}) if name.is_a?(Symbol) class_name = name.to_s.titleize.split(" ").join("") else class_name = name.split("/").map {|n| n.titleize.sub(" ", "") }.join("::") end class_name.constantize.new(self, args) end
This view_object helper method dynamically finds and instantiates the correct view object class based on the name provided. This way, we can call <%= view_object(:home_tabs, company: @company).html %>
from our view to instantiate a view object class called HomeTabs and automatically pass in the view context.
Creating our first View Object
The basic idea is that we can just create a plain old ruby class that ultimately spits out some HTML. Let’s start by creating the HomeTabs view object:
app/viewobjects/hometabs.rb:
class HomeTabs < ViewObject attr_reader :company def html content_tag :div, tabs.join('').html_safe, class: 'tabs' end private def after_init(args = {}) @company = args[:company] end def tabs [home_tab, dashboard_tab, company_tab].compact end def home_tab build_tab('Home', home_path, :home) end def dashboard_tab build_tab('Dashboard', dashboard_path, :dashboard) end def company_tab return nil unless can?(:manage, company) build_tab('Company Admin', company_path, :company) end def build_tab(text, path, tab_name) link_to text, path, class: tab_class(tab_name) end def tab_class(tab) active_class = active?(tab) ? 'is-active' : nil ['tabs--link', active_class].compact.join(' ') end def active?(tab) case tab when :home return true if context.controller_name == 'home' when :dashboard return true if context.controller_name == 'dashboards' when :company return true if context.controller_name == 'companies' end return false end end
Now in the view (home.html.erb):
<%= view_object(:home_tabs, company: @company).html %>
While this is quite a bit of code just to implement four tabs, the real value is realized when we want to make our next group of tabs. First, let’s try to figure out which methods in the above HomeTabs class are specific to the tabs rendered on the home page versus generic to all tabs throughout the app.
Methods specific to HomeTabs view object:- hometab- dashboardtab- company_tab
Methods generic to all tabs view objects:- html- tabclass- buildtab
Interface methods (i.e. every tabs view object must implement but implementation changes):- active?- tabs
Now, let’s refactor this into two classes (1) a generic “Tabs” class, and (2) the “HomeTabs” class
app/view_objects/tabs.rb:
class Tabs < ViewObject def html content_tag :div, tabs.join('').html_safe, class: 'tabs' end private # Interface Methods def tabs fail 'must be implemented by subclass' end def active?(tab) fail 'must be implemented by subclass' end # Generic Methods def tab_class(tab) active_class = active?(tab) ? 'is-active' : nil ['tabs--link', active_class].compact.join(' ') end def build_tab(text, path, tab) link_to text, path, class: tab_class(tab) end end
app/viewobjects/hometabs.rb:
class HomeTabs < Tabs private # Interface Methods def tabs [home_tab, dashboard_tab, company_tab].compact end def active?(tab) case tab when :home return true if context.controller_name == 'home' when :dashboard return true if context.controller_name == 'dashboards' when :company return true if context.controller_name == 'companies' end return false end # Specific Methods def home_tab build_tab('Home', home_path, :home) end def dashboard_tab build_tab('Dashboard', dashboard_path, :dashboard) end def company_tab return nil unless can?(:manage, company) build_tab('Company Admin', company_path, :company) end end
What I hope you’ve noticed is that the code in the HomeTabs class has been cut down to the core essence of what it means to be HomeTabs instead of any other type of tabs. That is, only code specific to HomeTabs is here – not code generic to all tabs. For example, CSS classes are not set within this HomeTabs view object, since the tabs’ CSS classes are the same for all tabs.
View Objects are Scalable
Now here’s where the beauty of View Objects really kicks in. Let’s create a new set of tabs: the settings tabs.
app/viewobjects/settingstabs.rb:
class SettingsTabs < Tabs private # Interface Methods def tabs [profile_tab, user_tab].compact end def active?(tab) case tab when :profile return true if context.controller_name == 'profiles' when :user return true if context.controller_name == 'users' end return false end # Specific Methods def profile_tab build_tab('Profile', profile_path(context.current_user.profile), :profile) end def user_tab build_tab('My Account', edit_user_registration_path(current_user), :user) end end
And then in the view (settings.html.erb):
<%= view_object(:settings_tabs).html %>
We need only follow the pattern already laid out in HomeTabs to implement a brand new set of fully functioning tabs. If we want to modify the way our tabs are structured, we can do so in one place: the generic “Tabs” view object. Further, should the logic become more complex surrounding what tabs to show under different circumstances, we have the power of object-oriented code to help ease the pain.
Ready to Build Something Great?
Partner with us to develop technology to grow your business.