Using components in Phoenix templates

As a mostly-Rails backend developer, I find Rails partials confusing. Don't get me wrong, they're useful. But they're hard to work with. In my experience, most of the times these partials will accept params, but there's no way to know what params they accept without looking at the partial content itself. And this makes me sad.

I usually write partials similar to frontend frameworks components. Minus the logic part, because I haven't found a way to have real components in Rails views. The most similar thing to what I'm looking for is Trailblazer's cells (which I really like!), but they're still not quite what I'd like.

We recently worked on project that had an admin dashboard built on Phoenix based on a Bootstrap template. This kind of templates usually add their own classes on top of Bootstrap, and those components require extra HTML structure so that everything looks as expected.

"Oh, it's the perfect use case for components!", I thought.

Componentizing Phoenix templates

Well, yes. This was the typical use case for partials. But Phoenix does not support partials explicitly as Rails does. Since Phoenix is very flexible, you can write a ComponentView and dump your shared layouts there:

<%= render ComponentView, "tabs.html" do %>
  <%= render ComponentView, "tab.html", name: "All Products" %>
  <%= render ComponentView, "tab.html", name: "Featured" %>
<% end %>

The tabs.html file contains the wrapper layout for a tabs element, and the tab.html contains the layout for a single tab.

You can give a little twist to that idea, and simplify it to this:

<%= component "tabs.html" do %>
  <%= component "tab.html", name: "All Products" %>
  <%= component "tab.html", name: "Featured" %>
<% end %>

Now we have a similar feature as Rails has, but since we're using a single view for all components, all component-related helpers would be written there. All together.

Concerned darkwraith meme.

Multiple views

Exploring alternative implementations of components I found Kim Lindholm's Phoenix component Folders repo. If you follow the suggestions in the README file, you'll be able to have multiple component views, so that helper methods can be isolated. This allows us to write something like this:

# lib/my_app_web/components/tabs/tabs_view.ex
defmodule MyAppWeb.Components.TabsView do
  alias MyAppWeb.Components.ComponentHelpers
  use MyAppWeb, {:view, ComponentHelpers.view_opts(:tabs)}
end

# in your template:
<%= component :tabs do %>
  <%= component :tabs, :tab, name: "All Products" %>
  <%= component :tabs, :tab, name: "Featured" %>
<% end %>

This got us the same functionality Rails has... with the same problems: what params does a component accept?

Using helper methods to know the needed params

The fact that I couldn't see, at a glance, the params needed to build a component bugged me. I could leave it as it was, really. After all, I had been developing like that with Rails for a long time. But Elixir has a powerful toolkit to generate static docs from the documentation in the code, and I wanted to use that.

Finally, I created helper methods. Some of them wrapped the calls to the component methods, others created template syntax directly:

def icon(%{icon_name: name, icon_color: color}) do
  content_tag :span, class: "icon-holder" do
    ComponentHelpers.component(:icon, color: color, icon: name)
  end
end

You could even use the ~E sigil to simplify the needed code:

def icon(%{icon_name: name, icon_color: color}) do
  ~E"""
  <span class="icon-holder">
    #{ComponentHelpers.component(:icon, color: color, icon: name)}
  </span>
  """
end

With that I could write static docs, and my IDE can catch it and give me hints about the needed parameters.

Conclusion

This is quite new and we probably need more opportunities to see how it matures, so I don't have a final decision on how to write components in Phoenix. I like the final approach using helper methods wrapping my components to get nice documentation, though.

What are your thoughts on this approach? Have you used something similar? Is there something we missed? Tell us on Twitter!

Cover photo by Ryan Quintal on Unsplash