Setup View Components in a Rails App

Create new Rails App

Make sure you have setup Rails. You can read the guide here:

https://guides.rubyonrails.org/getting_started.html

Before we being make sure we are running node version 18 or above:

nvm use 18

Create our new app rails-view-components

rails new rails-view-components --css=tailwind --javascript=esbuild
cd rails-view-components 

Spin up the server to test everything is running:

./bin/dev

Install ViewComponent gem

https://evilmartians.com/chronicles/viewcomponent-in-the-wild-supercharging-your-components

Next we will install the super handy package from Evil Martians that make working with components even better. It will install both the gems we need to continue:

rails app:template LOCATION="https://railsbytes.com/script/zJosO5"

Step through the installer. I like to keep components in the views directory.

Where do you want to store your view components? (default: app/frontend/components) app/views/components
Would you like to use dry-initializer in your component classes? (y/n) y
Do you use Stimulus? (y/n) y
Do you use TailwindCSS? (y/n) y
Would you like to create a custom generator for your setup? (y/n) y
Which template processor do you use? (1) ERB, (2) Haml, (3) Slim, (0) Other 1

Most of the above is pretty straight forward except for the dry-initializer gem:

https://dry-rb.org/gems/dry-initializer/3.1/

Update controller views location

To keep things tidy we will add controller views in to their own directory.

views/
  components/
  layouts/
  controllers/
    my_controller/
      index.html.erb
  mailers/
    my_mailer/
      message.html.erb

Add the following to app/controllers/application_controller.rb

append_view_path Rails.root.join("app", "views", "controllers")

Do the same for app/mailers/application_mailer.rb

append_view_path Rails.root.join("app", "views", "mailers")

Modify Generator

I like to skip the tests and preview file until I need them. We can do this by modifying the generator lib/generators/view_component/view_component_generator.rb:

class_option :skip_test, type: :boolean, default: true
class_option :skip_system_test, type: :boolean, default: true
class_option :skip_preview, type: :boolean, default: true

I also like to remove the with_collection_parameter from the template as I dont use it. Remove it from the file lib/generators/view_component/templates/component.rb.tt:

with_collection_parameter :<%= singular_name %>

Create our first component

Let's create a basic button component to test this out:

rails g view_component Button

Add a controller endpoint

We need a place to render our cool new button. Let's setup a controller in app/controllers called marketing_controller.rb

class MarketingController < ApplicationController
  def index
  end
end

This will need a view to render so create one in app/views/controllers/marketing/index.html.erb

<%= render(Button::Component.new) %>

We also need to add our route in config/routes.rb

root "marketing#index"

When you start up the server again you should see our awesome component!

Add Helper

We will add a helper to make rendering components a bit simpler and easier on the eyes

Add the following to app/helpers/application_helper.rb

def component(name, *args, **kwargs, &block)
  component = name.to_s.camelize.constantize::Component
  render(component.new(*args, **kwargs), &block)
end

We can now update our component render code:

<%= component("Button") %>

This helper makes it easy to render nested components like this:

<%= component("Button/Nested/Item", prop: "Test") %>

Update the base class app/views/components/application_view_component.rb to allow usage of helpers in the components:

class ApplicationViewComponent < ViewComponentContrib::Base
  extend Dry::Initializer

  include ApplicationHelper
end

Load Stimulus Controllers

First, install the js package esbuild-rails to easily load stimulus controllers:

yarn add esbuild-rails

Setup the esbuild.config.mjs file:

#!/usr/bin/env node
// Requires esbuild 0.17+
//
// Esbuild is configured with 3 modes:
//
// `yarn build` - Build JavaScript and exit
// `yarn build --watch` - Rebuild JavaScript on change
// `yarn build --reload` - Reloads page when views, JavaScript, or stylesheets change
//
// Minify is enabled when "RAILS_ENV=production"
// Sourcemaps are enabled in non-production environments

import * as esbuild from "esbuild"
import path from "path"
import rails from "esbuild-rails"
import chokidar from "chokidar"
import http from "http"
import { setTimeout } from "timers/promises"

const clients = []
const entryPoints = [
  "application.js"
]
const watchDirectories = [
  "./app/javascript/**/*.js",
  "./app/views/**/*.html.erb",
  "./app/assets/builds/**/*.css", // Wait for cssbundling changes
]
const config = {
  absWorkingDir: path.join(process.cwd(), "app/javascript"),
  bundle: true,
  entryPoints: entryPoints,
  minify: process.env.RAILS_ENV == "production",
  outdir: path.join(process.cwd(), "app/assets/builds"),
  plugins: [rails()],
  sourcemap: process.env.RAILS_ENV != "production"
}

async function buildAndReload() {
  // Foreman & Overmind assign a separate PORT for each process
  const port = parseInt(process.env.PORT)
  const context = await esbuild.context({
    ...config,
    banner: {
      js: ` (() => new EventSource("http://localhost:${port}").onmessage = () => location.reload())();`,
    }
  })

  // Reload uses an HTTP server as an even stream to reload the browser
  http
    .createServer((req, res) => {
      return clients.push(
        res.writeHead(200, {
          "Content-Type": "text/event-stream",
          "Cache-Control": "no-cache",
          "Access-Control-Allow-Origin": "*",
          Connection: "keep-alive",
        })
      )
    })
    .listen(port)

  await context.rebuild()
  console.log("[reload] initial build succeeded")

  let ready = false
  chokidar
    .watch(watchDirectories)
    .on("ready", () => {
      console.log("[reload] ready")
      ready = true
    })
    .on("all", async (event, path) => {
      if (ready === false)  return

      if (path.includes("javascript")) {
        try {
          await setTimeout(20)
          await context.rebuild()
          console.log("[reload] build succeeded")
        } catch (error) {
          console.error("[reload] build failed", error)
        }
      }
      clients.forEach((res) => res.write("data: update\n\n"))
      clients.length = 0
    })
}

if (process.argv.includes("--reload")) {
  buildAndReload()
} else if (process.argv.includes("--watch")) {
  let context = await esbuild.context({...config, logLevel: 'info'})
  context.watch()
} else {
  esbuild.build(config)
}

Update our package.json build command:

"build": "node esbuild.config.mjs",

Let's automatically load stimulus controllers from our components directory by adding the code to app/javascript/controllers/application.js

// ViewComponents in app/views/components
import viewComponentControllers from "./../../views/components/**/controller.js"

viewComponentControllers.forEach((controller) => {
  const { name, module } = controller

  // Tidy up controller name
  // ..--..--views--components--video-reviewer--controller.js => video-reviewer
  const controllerName = name
    .replaceAll('..--..--views--components--', '')
    .replaceAll('--controller.js', '')

  application.register(controllerName, module.default)
})

Styling our Button component

Now we can get in to the fun part!

Open up the app/views/components/button/component.html.erb

<button
  class="
    bg-blue-500
    font-bold
    px-4
    py-2
    rounded-lg
    text-white
  "
>
  <%= content %>
</button>

This is cool but what if we want to support variants?

Add the tailwind_merge gem:

bundle add tailwind_merge

Update tailwind.config.js to watch our component.rb files

module.exports = {
  content: [
    './app/assets/stylesheets/**/*.css',
    './app/helpers/**/*.rb',
    './app/javascript/**/*.js',
    './app/views/**/*.html.erb',
    './app/views/components/**/*.js',
    './app/views/components/**/*.rb',
  ]
}

Update our app/views/components/application_view_component.rb file:

class ApplicationViewComponent < ViewComponentContrib::Base
  extend Dry::Initializer

  include ApplicationHelper
  include ViewComponentContrib::StyleVariants

  style_config.postprocess_with do |classes|
    TailwindMerge::Merger.new.merge(classes.join(" "))
  end
end

Let's move our classes to the app/views/components/button/component.rb file:

# frozen_string_literal: true

class Button::Component < ApplicationViewComponent
  option :variant, default: proc { :default }

  # https://github.com/palkan/view_component-contrib#style-variants
  style do
    base {
      %w[
        bg-blue-500
        font-bold
        px-4
        py-2
        rounded-lg
        text-white
      ]
    }

    # The "compound" directive allows us to declare additional classes to add
    # when the provided combination is used
    # compound(variant: :outline, disabled: true) {
    #   %w[
    #     opacity-75
    #     bg-slate-300
    #   ]
    # }
  end
end

Then we can update our component.html.erb class to this:

<button
  class="<%= style() %>"
>
  <%= content %>
</button>

If we refresh the page the button should still render correctly.

Let's add a variant called variant (yes that's a bit weird but roll with it!):

# frozen_string_literal: true

class Button::Component < ApplicationViewComponent
  option :variant, default: proc { :primary }

  # https://github.com/palkan/view_component-contrib#style-variants
  style do
    base {
      %w[
        font-bold
        px-4
        py-2
        rounded-lg
      ]
    }

    variants {
      variant {
        primary {
          %w[
            bg-blue-500
            text-white
          ]
        }
        outline {
          %w[
            border
            border-blue-500
            text-blue-500
          ]
        }
        ghost {
          %w[
            text-blue-500
          ]
        }
      }
    }

    defaults {
      {
        variant: :primary,
      }
    }

    # The "compound" directive allows us to declare additional classes to add
    # when the provided combination is used
    # compound(variant: :outline, disabled: true) {
    #   %w[
    #     opacity-75
    #     bg-slate-300
    #   ]
    # }
  end
end

Now we can update the html:

<button
  class="<%= style(variant:) %>"
>
  <%= content %>
</button>

Adding Stimulus Controller

We won't be doing anything crazy here but I just want to show you how easy it is!

Update app/helpers/application_helper.rb

module ApplicationHelper
  def component(name, *args, **kwargs, &block)
    component = name.to_s.camelize.constantize::Component
    render(component.new(*args, **kwargs), &block)
  end

  def component_identifier(name)
    component = "#{name.to_s.camelize}::Component".constantize
    component.identifier
  end
end

Add the methods too our ApplicationViewComponent file app/views/components/application_view_component.rb

class ApplicationViewComponent < ViewComponentContrib::Base
  extend Dry::Initializer

  include ApplicationHelper
  include ViewComponentContrib::StyleVariants

  style_config.postprocess_with do |classes|
    TailwindMerge::Merger.new.merge(classes.join(" "))
  end

  class << self
    def component_name
      @component_name ||= name.sub(/::Component$/, "").underscore
    end

    def identifier
      @identifier ||= component_name.gsub("_", "-").gsub("/", "--")
    end

    def self.identifier
      identifier
    end
  end
end

Let's create our controller at app/views/components/button/controller.js

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  connect(){
    console.log("Connected", this.element)
  }

  triggerAlert(e){
    const { target: { dataset: { message } } } = e
    alert(message)
  }
}

Link the controller to our component:

<button
  class="<%= style(variant:) %>"
  data-action="click-><%= component_identifier("Button") %>#triggerAlert"
  data-controller="<%= component_identifier("Button") %>"
  data-message="<%= alert_message %>"
>
  <%= content %>
</button>

Add the option to the component.rb

option :alert_message, default: proc { "Hello World" }

We should now see the connected log message in our console and when you click the button the alert should show.

Conclusion

And that's it! We now have components running in rails that can be connected to Stimulus to add javascript functionality as well as styling variants with tailwind.

View More Posts

Portfolio built using the open source project by

Initium Studio

Create your own Next.js Portfolio with Contentful CMS by forking this repo onGitHub

Blog

|

Projects

|

Health

|

Recipes

|

About

|

Contact

2024 Greeff Consulting Pty Ltd