Restructuring ViewComponents

I really like working with ViewComponents in Rails. There are so many positives compared to Action View, including:

  • a separation of concerns by allowing views to remain presentational, and keeping logic in a view model;
  • fast and comprehensive testing;
  • a strict interface that requires all parameters to be predefined; and
  • 10x better performance than Action View partials.

One thing that has always annoyed me, however, is the file structure. By default, all files are scattered in the same directory, which can be hard to navigate, particularly if you have CSS and JS bundled with your component.

ViewComponent does offer the ability to sidecar your assets, but you still need to keep the Ruby file outside of the sidecar directory. While this can substantially reduce the clutter, the default ordering in the VS Code Explorer creates a separation between the Ruby file and the sidecar directory.

My first attempt at making the file structure of ViewComponent feel nicer was to simply change the ordering of the VS Code Explorer, but I soon realised that I consider the default ordering to be far better in most cases; it was only ViewComponent that was causing me problems.

This week I was introduced to the view_component-contrib gem. One of their features is restructuring ViewComponents so that all related files are in the same directory, and you can drop the term “component” from your components. Instead of:

app/
  components/
    example_component/
      example_component.css
      example_component.html.erb
      example_component.js
    example_component.rb

You can have:

app/
  components/
    example/
      component.html.erb
      component.rb
      index.css
      index.js

While I appreciate the out-of-the-box thinking, I don’t like this either. I’ve encountered issues in the past when lots of files are called the same thing. When searching for a component, even if you include the directory in your search, you’ll often need to check the full path to be sure you are about the open the correct file:

Because Ruby is a dynamically typed language, it is also harder to click a reference to go to the class definition. The modern tooling is getting very good at defaulting the selection to the correct class, but you still need to stop and check the results:

Instead, I’ve been experimenting with changing the Rails default, using Components as a namespace, placing all component files in the same directory, but not including that presentational directory in the namespace.

Zeitwerk#

Thankfully, the autoloader used by Rails, Zeitwerk, is quite configurable. To get the outcome I was looking for, I created an initializer, config/initializers/autoloader.rb:

# frozen_string_literal: true

# The default configuration of View Components can be frustrating. Either you have lots of files
# littered around the app/components directory, or you sidecar your assets but have your Ruby file
# outside of the sidecar.
# In the latter case, the VS Code default is to organise the explorer so that directories are all
# first, followed by files. While this can be changed, the default sort is generally better, but it
# separates the Ruby and asset files.
#
# The following configuration makes it possible for:
# - the components directory to become a namespace;
# - all files related to a component to be grouped in a single directory; and
# - the presentational/grouping directory to be ignored in namespacing.
#
# For example, a component called `ReusableThing` would be defined in:
# `app/components/reusable_thing/reusable_thing.rb`
# resulting in the class:
# `Components::ReusableThing`
#
# Alternatively, you might like to nest/group your components. For example, if you wanted to group
# all components related to forms, you could have:
# `app/components/forms/text_input/text_input.rb`
# resulting in the class:
# `Components::Forms::TextInput`
#
# N.B. this configuration assumes that, if a directory has sub-directories, it should be ignored.
# This means you couldn't do something like:
# - `app/components/forms/text_input/text_input.rb`
# - `app/components/forms/text_input/variation/variation.rb`
# In this case, TextInput would not be collapsed, and so it would be expected that text_input.rb
# defines `Components::Forms::TextInput::TextInput`

# See https://edgeguides.rubyonrails.org/autoloading_and_reloading_constants.html#custom-namespaces
module Components; end

components_dir = Rails.root.join("app/components").to_s.freeze

Rails.autoloaders.main.push_dir(components_dir, namespace: Components)

# Only required for Rails < 7.1
ActiveSupport::Dependencies.autoload_paths.delete(components_dir)
Rails.application.config.watchable_dirs[components_dir] = [:rb]

def collapse_presentational_directories(path)
  children = Dir.children(path)

  if children.any? { File.directory?("#{path}/#{_1}") }
    collapse_children(path, children)
  else
    Rails.autoloaders.main.collapse(path)
  end
end

def collapse_children(path, children)
  children.each do |child|
    child_path = "#{path}/#{child}"
    next unless File.directory?(child_path)

    collapse_presentational_directories(child_path)
  end
end

Dir.children(components_dir).
  find_all { File.directory?("#{components_dir}/#{_1}") }.
  each { collapse_presentational_directories("#{components_dir}/#{_1}") }

Generators#

The last piece of the puzzle is to reconfigure the generators to know about the structure, otherwise calling rails g component ReusableThing will put the files in the default locations with the default naming structure.

To minimise duplication of effort, I decided to extend upon ViewComponent’s AbstractGenerator:

# lib/rails/generators/hash_not_adam/abstract_generator.rb
# frozen_string_literal: true

require "rails/generators/abstract_generator"

module HashNotAdam
  module AbstractGenerator
    include ViewComponent::AbstractGenerator

    private

    def destination_directory
      File.join(component_path, class_path, destination_file_name)
    end

    def destination_file_name
      file_name
    end
  end
end

Then I created custom generators for the features I use (Ruby/ERB/RSpec).

# lib/rails/generators/component/component_generator.rb
# frozen_string_literal: true

require "rails/generators/hash_not_adam/abstract_generator"

module Rails
  module Generators
    class ComponentGenerator < Rails::Generators::NamedBase
      include HashNotAdam::AbstractGenerator

      source_root File.expand_path("templates", __dir__)

      argument :attributes, type: :array, default: [], banner: "attribute"
      check_class_collision

      class_option :inline, type: :boolean, default: false
      class_option :locale, type: :boolean, default: ViewComponent::Base.config.generate.locale
      class_option :parent, type: :string, desc: "The parent class for the generated component"
      class_option :preview, type: :boolean, default: ViewComponent::Base.config.generate.preview
      class_option :stimulus, type: :boolean,
                              default: ViewComponent::Base.config.generate.stimulus_controller

      def create_component_file
        template "component.rb", File.join(destination_directory, "#{file_name}.rb")
      end

      hook_for :test_framework

      hook_for :preview, type: :boolean

      hook_for :stimulus, type: :boolean

      hook_for :locale, type: :boolean

      hook_for :template_engine do |instance, template_engine|
        instance.invoke template_engine, [instance.name]
      end

      private

      def parent_class
        return options[:parent] if options[:parent]

        ViewComponent::Base.config.component_parent_class || default_parent_class
      end

      def initialize_signature
        return if attributes.blank?

        attributes.map { |attr| "#{attr.name}:" }.join(", ")
      end

      def initialize_body
        attributes.map { |attr| "@#{attr.name} = #{attr.name}" }.join("\n    ")
      end

      def initialize_call_method_for_inline?
        options["inline"]
      end

      def default_parent_class
        defined?(ApplicationComponent) ? ApplicationComponent : ViewComponent::Base
      end
    end
  end
end
# lib/rails/generators/component/templates/component.rb.tt
# frozen_string_literal: true
<% namespaces = class_name.split("::"); last_namespace_index = namespaces.count - 1 %>
module Components
<% namespaces.each_with_index { |namespace, index| %><%=
  " " * ((index + 1) * 2)
%><%=
  if index == last_namespace_index
    "class #{namespace} < #{parent_class}\n"
  else
    "module #{namespace}\n"
  end
%><% } %>
  <%- if initialize_signature -%>
    def initialize(<%= initialize_signature %>)
      <%= initialize_body %>
    end
  <%- end -%>
  <%- if initialize_call_method_for_inline? -%>
    def call
      content_tag :h1, "Hello world!"<%= ", data: { controller: \"#{stimulus_controller}\" }" if options["stimulus"] %>
    end
  <%- end -%>
<% last_namespace_index.downto(0) { |index| %><%=
  " " * ((index + 1) * 2)
%><%=
  "end#{"\n" if index.positive?}"
%><% } %>
end
# lib/rails/generators/erb/component_generator.rb
# frozen_string_literal: true

require "rails/generators/erb"
require "rails/generators/hash_not_adam/abstract_generator"

module Erb
  module Generators
    class ComponentGenerator < Base
      include HashNotAdam::AbstractGenerator

      source_root File.expand_path("templates", __dir__)
      class_option :inline, type: :boolean, default: false
      class_option :stimulus, type: :boolean, default: false

      def engine_name
        "erb"
      end

      def copy_view_file
        super
      end

      private

      def data_attributes
        if options["stimulus"]
          " data-controller=\"#{stimulus_controller}\""
        end
      end
    end
  end
end
# lib/rails/generators/erb/templates/component.html.erb.tt
<div<%= data_attributes %>>Add <%= class_name %> template here</div>
# lib/rails/generators/rspec/component_generator.rb
# frozen_string_literal: true

module Rspec
  module Generators
    class ComponentGenerator < ::Rails::Generators::NamedBase
      source_root File.expand_path("templates", __dir__)

      def create_test_file
        template "component_spec.rb", File.join("spec/components", class_path, "#{file_name}_spec.rb")
      end
    end
  end
end
# lib/rails/generators/erb/templates/component_spec.rb.tt
# frozen_string_literal: true

require "rails_helper"

RSpec.describe Components::<%= class_name %> do
  pending "add some examples to (or delete) #{__FILE__}"

  # it "renders something useful" do
  #   expect(
  #     render_inline(described_class.new(attr: "value")) { "Hello, components!" }.css("p").to_html
  #   ).to include(
  #     "Hello, components!"
  #   )
  # end
end

Conclusion#

This is very much a proof-of-concept that I’ve only tested at small scale, and I’d love to hear what other people think (especially if you have concerns).

Until I’ve seen this running for longer, and with many more components, I’m going to be concerned that:

  • there could be edge cases I’ve not considered;
  • iterating over the file system to collapse directories might not be efficient at scale; and
  • it won’t be obvious to anyone new to the project that Zeitwerk has been modified.

I also haven’t yet looked at Previews, but I would like to move those into the same directory. This is something that view_component-contrib is already doing. While I would want to use a different naming convention, it should be easy enough to reverse-engineer.