Extending Rails partials using local_assigns
Rails partials often start out as logic-less extractions and grow in complexity as more views rely on them. In over 10 years of Rails development the story of partial evolution usually plays out the same time after time.
Disclaimer: This post illustrates one problem / solution, it's difficult to fabricate a situation that applies to all partial usages. In our example we are using partials with local variables, not instance variables, which we could write a whole separate blog post on why local variables are preferable to instance variables inside a partial.
Humble, Logic-less beginnings
Let's say you have a partial that renders a shared footer in a file called _footer.html.erb
. It exists for the sole purpose of DRY-ing up all the pages that display the company's privacy policy, terms of service and copyright information.
...
<%= render 'footer' %>
<footer>
<ul class="inline">
<li>
<%= link_to 'Privacy Policy', privacy_policy_url %>
</li>
<li>
<%= link_to 'Terms of Service', terms_of_service_url %>
</li>
</ul>
<p>© 2017 Overstuffed Gorilla LLC</p>
</footer>
Add some dynamic data
Let's imagine you've been asked to implement a new feature that displays a user's blog and Facebook accounts right above the company footer links. To implement this request, the footer.html.erb
will now accept a two local variables: facebook_uri
and blog_uri
.
<%= render 'footer', blog_uri: @current_user.blog_uri, facebook_uri: @current_user.facebook_uri %>
<footer>
<section class="user-links">
<ul class="inline">
<li>
<%= link_to 'Blog', blog_uri %>
</li>
<li>
<%= link_to 'Facebook', facebook_uri %>
</li>
</ul>
</section>
<section class="company-links">
<ul class="inline">
<li>
<%= link_to 'Privacy Policy', privacy_policy_url %>
</li>
<li>
<%= link_to 'Terms of Service', terms_of_service_url %>
</li>
</ul>
</section>
<p>© 2017 Overstuffed Gorilla LLC</p>
</footer>
Conditional Logic
Those two local variables we added above made a big assumption and that is, every page that tries to render the _footer.html.erb
partial must have knowledge of these two added variables.
How can we avoid having to set these two variables in situations where current_user
is missing, like a login page? Let's add a conditional check inside our partial and see what that looks like.
<footer>
<% if blog_uri && facebook_uri %>
<section class="user-links">
<ul class="inline">
<li>
<%= link_to 'Blog', blog_uri %>
</li>
<li>
<%= link_to 'Facebook', facebook_uri %>
</li>
</ul>
</section>
<% end %>
<section class="company-links">
<ul class="inline">
<li>
<%= link_to 'Privacy Policy', privacy_policy_url %>
</li>
<li>
<%= link_to 'Terms of Service', terms_of_service_url %>
</li>
</ul>
</section>
<p>© 2017 Overstuffed Gorilla LLC</p>
</footer>
At first glance this looks like a solution. Heck it would work in a regular view, why not a partial?
Give this a try and you'll see it works when blog_uri
and facebook_uri
are set, but fails with NameError: undefined local variable or method 'blog_uri'
everywhere else. So what do we do?
The anti-pattern
I've seen this all too often when folks hit this error. They refactor all partial calls into something like this:
<%= render 'footer', blog_uri: (@current_user ? @current_user.blog_uri : nil), facebook_uri: (@current_user ? @current_user.facebook_uri : nil) %>
I won't argue that it eliminates the rendering error, but it adds way more complexity to using the partial.
The benefit of a view partial is it's ability to DRY's up views and reducing efforts to make code changes. I'd argue adding the conditional logic to always set the blog_uri
and facebook_uri
stands in opposition to the benefit partials aim to provide. Each usage of a partial now has this parameter conditional and for each parameter added in the future will make things worse.
Not to mention setting variables that have no meaning in the current view's context will raise flags for fellow developers new to the codebase. Seeing a variable set to nil (ie. blog_uri: nil
) when no blog ever exists causes confusion like "Wait, why would there ever be a blog uri on this page? Should I just remove it if it's always nil
?"
The right approach, use local_assigns
Turns out Rails has had an answer for so long that even DHH forgot it existed, it's the local_assigns
method. Using local_assigns
:
- partials can check for variable existence without triggering a
NameError
- optional variable arguments can be skipped if they are not needed
- each render call only mentions the relevant context instead of extra
blog_uri: nil
arguments
... on logged in pages
<%= render 'footer', blog_uri: @current_user.blog_uri, facebook_uri: @current_user.facebook_uri %>
... on other pages
<%= render 'footer' %>
<footer>
<% if local_assigns[:blog_uri] && local_assigns[:facebook_uri] %>
<section class="user-links">
<ul class="inline">
<li>
<%= link_to 'Blog', local_assigns[:blog_uri] %>
</li>
<li>
<%= link_to 'Facebook', local_assigns[:facebook_uri] %>
</li>
</ul>
</section>
<% end %>
<section class="company-links">
<ul class="inline">
<li>
<%= link_to 'Privacy Policy', privacy_policy_url %>
</li>
<li>
<%= link_to 'Terms of Service', terms_of_service_url %>
</li>
</ul>
</section>
<p>© 2017 Overstuffed Gorilla LLC</p>
</footer>