These days, most front-end developers agree that UIs should be built in a component-oriented fashion. If you’re curious about that larger conversation, there are plenty of good resources on that out there: We’d highly recommend the excellent Style Guide Podcast — yes, all twelve episodes are well worth listening to — as well as Atomic Design to get a sense of why this matters.

Implementing a component-based approach can be a challenge. Generally speaking, it’s a good idea to maintain an application-independent style guide / component library, not least because it tends to result in more well-crafted components (e.g. regarding documentation, reusability and robustness). These days, we can choose from numerous tools to set up a library like that (e.g. Pattern Lab or Fractal).

Even so, you probably still want some sort of abstraction to generate components' server-side markup[1] within your application, both for convenience and encapsulation:

<image-gallery>
    <ul>
        <li class="is-active">
            <img src="…" alt="…">
        </li>
        <li>
            <img src="…" alt="…">
        </li></ul>
</image-gallery>

(<image-gallery> here is a custom element, which unobtrusively takes care of client-side augmentation.)

Whenever we need to generate such complex HTML structures within our templates, we just want to invoke something like a function with the respective parameters:

component("image-gallery", images=[…], selected_index=0)

Well, that’s pretty much exactly what we’ve done in our latest project — which happens to use Haml (“HTML Abstraction Markup Language”), though this approach should work just the same with ERB or whatever templating language you prefer[2]:

:ruby
  # parameters are passed into the component's template explicitly
  images = data.fetch(:images) # required
  selected_index = data.fetch(:selected_index, 0)

%image-gallery
  %ul
    - images.each_with_index do |image, index|
      %li{ class: index == selected_index ? "is-active" : nil }
        = image_tag image.src, alt: image.alt

Note that any parameters are passed in explicitly, which ensures that each component has a well-defined contract.

%main
  %h1 Portfolio
  %p Here's a selection of my favorite images:
  = component :image_gallery, data: { images: @images }

%footer
  %p © 2017 Unsigned Artist

Components may also support blocks to allow for composition:

= component :order_form, data: { logo: logo } do
  %footer
    %p All photos half price until solstice. Terms and conditions apply.
    = component :discount_voucher

Behind the scenes, component is just a tiny wrapper around the built-in render Rails helper:

module ComponentHelper
  def component(name, data: {}, &block)
    render_component("#{name}/#{name}", { data: data }, &block)
  end

  private

  def render_component(name, locals, &block)
    if block_given?
      # using `layout` is a trick to allow passing blocks to partials
      # (cf. http://stackoverflow.com/a/2952056)
      render layout: name, locals: locals, &block
    else
      render partial: name, locals: locals
    end
  end
end

Whenever component is invoked, it renders the respective markup partial from the corresponding directory in app/components[3] (e.g. app/components/image_gallery/_image_gallery.html.haml). That directory typically also contains whatever else the component might require, such as CSS and JavaScript assets as well as i18n translation files:

.
├── app
│   ├── components
│   │   ├── …
│   │   ├── image_gallery
│   │   │   ├── _image_gallery.html.haml
│   │   │   ├── _image_gallery.scss
│   │   │   ├── image_gallery.js
│   │   │   └── image_gallery.yml
│   │   └── …

We’d be happy to elaborate on that aspect some other time — let us know in the comments.

  1. Distinguishing between server–side base markup and augmented client–side DOM structures improves performance, robustness and generally makes us a good citizen of the web — but that's a topic for another day.  ↩

  2. At least one of the authors is not particularly, err, partial to Haml.  ↩

  3. For this to work, we've extended ApplicationController to include append_view_path Rails.root.join("app", "components").  ↩