Build Rails Apps with Components

Automatically render Phlex and ViewComponents for controller actions with Superview

If you’ve ever worked on a Rails app, you’ve probably run into problems with the view layer turning into a mess as the app gets older or the team gets bigger. Rails attempts to tame this problem with solutions like strict locals, but I’ve found them to be cumbersome and view them as a worse implementation of Ruby method definitions.

Fortunately component libraries like Phlex and ViewComponent use Ruby method definitions to create a more sane boundary between application code and views, but using them as controller views hasn’t been straightforward and requires a lot of boilerplate.

That’s where Superview comes in—Superview is a gem that makes it possible to build Rails applications from the ground-up using nothing but components. If a class instance responds to renders_in and has attr_writer methods, Superview can assign the controllers’ instance variables to the component and render it if the class name matches the action name.

Implicit template rendering in Rails

Before we dive into Superview, let’s understand how good we have it in Rails with view templates. Today a typical Rails blog posts controller action might look like this:

# ./app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action { @post = Post.find(params[:id]) }
  # Implicitly renders ./app/views/posts/show.html.erb
end

Out of the box, Rails is configured to look for views in the ./app/views directory for the requested format. The major accomplishment is the code we didn’t have to write to find and render the template. Can we accomplish the same thing with component views? Let’s find out.

The tedious way of manually rendering Phlex or ViewComponent views

Phlex and ViewComponent views can be rendered in a Rails controller today, but it requires a bit of boilerplate that starts to feel really repetative as the number of actions grows in your apps.

# ./app/controllers/posts_controller.rb
class PostsController < ApplicationController
  before_action { @post = Post.find(params[:id]) }

  def show
    respond_to do |format|
      # Rendering a component without Superview đź‘Ž
      format.html { render PostComponent.new(post: @post) }
    end
  end
end

The lines of code start to add up when you multiply these lines of code across each action in your Rails application. There has to be a better way, right?

Automatically render components with Superview

Since we’ve been spoiled by implicit template rendering in Rails, nobody really wants to write this boilerplate every time they want to render a component. That’s where Superview can help, and just for fun let’s make peoples’ heads explode 🤯 with an example of inline views in a controller.

# ./app/controllers/posts_controller.rb
class PostsController < ApplicationController
  include Superview::Actions

  before_action { @post = Post.find(params[:id]) }

  # The `Show` class maps to the `show` action đź‘Ť
  class Show < ApplicationComponent
    attr_writer :post

    def view_template
      h1 { @post.title }
      article { @post.body }
    end
  end

  # The `Edit` class maps to the `edit` action đź‘Ť
  class Edit < ViewComponent::Base
    attr_writer :post

    erb_template <<~ERB
      <h1>Editing Post</h1>
      <form>
        <input type="text" value="<%= @post.title %>">
        <textarea><%= @post.body %></textarea>
      </form>
    ERB
  end
end

With Superview installed, the Show and Edit views will render for their corresponding actions.

Installing Superview

Of course, you’ll need to install Superview to get the controller above working with Superview::Actions. You can add it to your Rails app by running:

Run from the root of your Rails app
bundle add superview
Don't forget to restart your Rails server

Restart your Rails server and you should be good to go.

Extracting components into their own view files

I’ve found that inline views are polarizing in Rails. I love them because I can see my view code right next to my controller actions and it feels like Sinatra, but lots of people want their views in the corresponding ./app/views/posts directory. Fortunately for those who prefer to keep their views far, far away from the controller, it’s possible to move your views into their own folder.

Let’s move our inline views from above into the ./app/views/posts directory.

tree -R ./app/views/posts
|-- show.rb
|-- edit.rb

There’s a bit of Rails app configuration we need to do to get these views automatically loading with Zeitwerk, which is the code loader Rails uses to make your application work.

Add app/views to Rails autoload paths

We need to add the following to our config/application.rb file so Rails autoloader, Zeitwerk, picks up the view files.

# ./config/application.rb
module YourApp
  class Application < Rails::Application
    config.autoload_paths += Rails.root.join("app/views")
  end
end

Namespace the views

Zeitwerk will automatically load the files in the ./app/views/posts directory, but only if we namespace the view in the Posts module. Here’s what view files look like, namespaced, in the ./app/views/posts directory.

# ./app/views/posts/show.rb
class Posts::Show < ApplicationComponent
  attr_writer :post

  def view_template
    h1 { @post.title }
    article { @post.body }
  end
end

Don’t forget the edit view!

# ./app/views/posts/edit.rb
class Posts::Edit < ViewComponent::Base
  attr_writer :post

  erb_template <<~ERB
    <h1>Editing Post</h1>
    <form>
      <input type="text" value="<%= @post.title %>">
      <textarea><%= @post.body %></textarea>
    </form>
  ERB
end

If you’re wondering, “Why Posts and not Post”, it’s because the singular name Post is likely taken by a model. Pluralizing the namespace for views makes it less likely that you’ll run into a situation where a model and view namespace have the same name.

Now there’s just one more thing…

Include the Posts module in the controller

We need to include the Posts module in the controller so that the view files are loaded by Zeitwerk.

# ./app/controllers/posts_controller.rb
class PostsController < ApplicationController
  include Superview::Actions
  include Posts

  before_action { @post = Post.find(params[:id]) }
end

The include Posts line loads the Show and Edit constants into the PostsController namespace, which means Superview can resolve them for the show and edit actions. If you have other controllers that need to render these views, you’d include the Posts module in those controllers as well.

Common “edge” cases

Good abstractions always have escape hatches for situations where the implicit abstraction won’t work, or needs a little help. Let’s look at how a few of those edge cases are handled.

Multiple formats in an action

For example, let’s say we need to render a JSON format in the same controller; we can use the render method to render the JSON response.

# ./app/controllers/posts_controller.rb
class PostsController < ApplicationController
  include Superview::Actions
  include Posts

  before_action { @post = Post.find(params[:id]) }

  def show
    respond_to do |format|
      # The `component` method returns the component class for the action
      format.html { render component }
      # Renders the `show.json` view in the `./app/views/posts` directory
      format.json
    end
  end
end

The component method will return the component class for the action, which is Posts::Show in this case. If you need to render a different component, you can pass the component class to the render method.

Rendering a different component in an action

One common pattern for the update action is to render the edit view if there’s an error so the user can see form errors and update their input. We can render the edit component in the update action by passing the Edit class to the component method.

# ./app/controllers/posts_controller.rb
class PostsController < ApplicationController
  include Superview::Actions
  include Posts

  before_action { @post = Post.find(params[:id]) }

  def update
    if @post.update(post_params)
      redirect_to @post
    else
      render component Edit
    end
  end
end

Selectively loading views in the controller

If you have a controller that only needs to render a component for a single action, you can override the component_view method to return the component class for that action.

# ./app/controllers/posts_controller.rb
class PostsController < ApplicationController
  include Superview::Actions

  before_action { @post = Post.find(params[:id]) }

  # Set the constant in the controller to the constant
  # of the view you wish to render.
  Show = Posts::Show
end

Conclusion

Superview makes it possible to build Rails applications from the ground-up using nothing but components. If it responds to renders_in and attr_writer, Superview can assign the controllers’ instance variables to the component and render it if the class name matches the action name.

You can start using Superview in new and existing Rails apps by running:

Run from the root of your Rails app
bundle add superview

And then including the Superview::Actions module in your controllers. The module is backwards compatible with existing Rails views, so you can start using components in new controllers and views as you see fit.

Now you have another tool to build more maintainable Rails applications with components and automatically render Phlex and ViewComponents for controller actions with Superview.

Support this blog 🤗

If you like what you read and want to see more articles like this, please consider using Terminalwire for your web application’s command-line interface. In under 10 minutes you can build a command-line in your favorite language and web framework, deploy it to your server, then stream it to the Terminalwire thin-client that runs on your users desktops. Terminalwire manages the binaries, installation, and updates, so you can focus on building a great CLI experience.