[RAILS] ViewComponentReflex A note I saw by touching

What is this?

I wanted to try ViewComponentReflex, which is something that Rails can implement the process of rewriting values ​​with ajax, so I tried various notes.

https://github.com/joshleblanc/view_component_reflex

motivation

--I don't like JavaScript so much, so I wanted to write it only on the server side if I only wanted to update the data on the screen. --It may be a way of thinking that is incompatible with people who want to do various things with JavaScript + API

Prior knowledge

ViewComponentReflex is an extension of ViewComponent. Therefore, first of all, minimum knowledge of ViewComponent is required.

Also, the part (miscellaneous) that communicates with an ajax-like feeling is written based on stimulus_reflex, but it is in a good form without being aware that it is just a rough process.


From here, we will first roughly describe ViewComponent, and then look at ViewComponentReflex.


What is ViewComponent in the first place?

Outline

--To enable the HTML spit out by Rails to be componentized like React etc. ――When componentized, it is convenient to test the view and it is easy to follow the data.

What is different from partials?

Rails already has partials for componentizing parts of HTML.

Something like this


//Caller
<div>
  <%= render :partial => "(template_name))" %>
</div>

//Called side
<p>This guy is displayed</p>

//result
<div>
  <p>This guy is displayed</p>
</div>

Roughly speaking, the following is different.

--ViewComponent is faster to load ――It is written that it is about 10 times faster, but it depends on the situation --Easy to test View --Easy to write View test code

To be honest, it looks like an evolved version of partial.

(personally...) Partial is simpler for static HTML, such as embedding Google Analytics tags. If you want to expand variables in a list etc., ViewComponent is faster and better. If you want to separate CSS etc. into components, ViewComponent seems to be better even without variable expansion.


From here, a memo when implementing the sample according to the guide. Since it is implemented in slim, please read it as appropriate.

https://viewcomponent.org/


Installation Rails 6.1 fits in smoothly. Monkey patch is required for Rails 5.0+. It may be easier to upgrade to Rails 6.1 first.

gem "view_component", require: "view_component/engine"

Basic movement

If you define Component in Ruby, HTML in the same directory will be loaded automatically. To specify a variable for Component, specify it with initialize.

app/components/example/component.rb


module Example
  class Component < ViewComponent::Base
    def initialize(title:)
      @title = title
    end
  end
end

app/components/example/component.html.slim


h1 = @title

When calling, render Example :: Component.new will be done on the View side. If there are no variables, just new is needed.

Caller.slim


= render Example::Component.new(title: 'hogehoge')

If you write this roughly, the HTML with the expanded components will be output.

Output result.html


<h1>hogehoge</h1>

When you want to specify the area of ​​content

If you want to pass not only variables but also HTML itself to the component, do as follows.

How to pass a block element on call

This is the same as the above-mentioned normal time. Because it automatically expands block elements to content without using special variables.

app/components/example2/component.rb


module Example2
  class Component < ViewComponent::Base
    def initialize(title:)
      @title = title
    end
  end
end

Place content where you want the block element to expand.

app/components/example2/component.html.slim


h1 = @title
div
  = content

I will pass HTML as a block element at the time of calling.

Caller.slim


= render Example2::Component.new(title: 'hogehoge') do |component|
  h2
    |You can write various HTML here

Block elements are automatically expanded to content.

Output result.html


<h1>hogehoge</h1>
<div><h2>You can write various HTML here</h2></div>

How to name each content

You can name it according to the location you want to expand, not just content. This is convenient when you want to describe header and hooter separately.

Name the content areas with with_content_areas.

app/components/example3/component.rb


module Example3
  class Component < ViewComponent::Base
    with_content_areas :header, :footer

    def initialize(title:)
      @title = title
    end
  end
end

Place a named content area. content can be used at any time.

app/components/example3/component.html.slim


div
  = header
div
  = content
div
  = footer

By creating a block of component.with (: content area name) at the time of calling, that element is expanded in the content area.

Caller.slim


= render Example3::Component.new(title: 'hogehoge') do |component|
  = component.with(:header) do
    |This is the header
  = component.with(:footer) do
    |This is a footer
  h2
    |This will be developed as content

Expanded to the corresponding location of header and footer. Also, the part not specified in component.with is expanded as content, so the contents of h2 are displayed in the middle.

Output result.html


<div>This is the header</div>
<div>
  <h2>This will be developed as content</h2>
</div>
<div>This is a footer</div>

Slots ViewComponent has a mysterious concept called Slots. Slots allow you to call content consisting of multiple blocks from a single ViewComponent.

Roughly speaking, the caller is a single line, but it will be able to handle nested components.

Slots type

The type changes depending on whether it is read only once or multiple times.

renders_one Used when setting a component that is read only once, such as a header, as a slot.

Example: renders_one: header

renders_many Set to a component that is called multiple times from one parent component, such as a comment to an article.

Example: renders_many: comments

How to call Slots

It is divided into three types.

transfer

This is a simple form.

Define the parent component.

app/components/example4/component.rb


module Example4
  class Component < ViewComponent::Base
    include ViewComponent::SlotableV2
    
    #If it is in the same class, specify the component with a character string
    renders_one :header, 'HeaderComponent'

    #When calling from another file, specify the class directly
    #Renders to call the post component multiple times_many is specified
    renders_many :posts, ExamplePost::Component

    class HeaderComponent < ViewComponent::Base
      attr_reader :title

      def initialize(title:)
        @title = title
      end
    end
  end
end

You can call the header and posts defined above from within the parent component. By doing so, each child component will be called.

app/components/example4/component.html.slim


div
  / TODO:For some reason html is expanded as a character string, so html_with escape
  = html_escape header
div
  - posts.each do |post|
    = html_escape post

The contents of each child component look like this.

app/components/example4/header_component.html.slim


div
  h1 = @title
  div
    = content

app/components/example_post/component.html.slim


h5 = @title

app/components/example_post/component.rb


module ExamplePost
  class Component < ViewComponent::Base
    def initialize(title:)
      @title = title
    end
  end
end

The caller calls the parent component directly. Also, the caller sets the value to the component set by renders_one and renders_many. Of course, the caller may loop around here in the form of @ posts.each ~ ~.

Caller.slim


div
  = render Example4::Component.new do |c|
    = c.header(title: "hogehoge") do
It's the contents of the p header
    = c.post(title: "The first one")
    = c.post(title: "Second")

Just by calling the parent component, you can see that the child component is also expanded without permission.

Output result.html


<div>
  <div>
    <h1>hogehoge</h1>
    <div>
      <p>It's the contents of the header</p>
    </div>
  </div>    
</div>

<div>
  <h5>The first one</h5>
  <h5>Second</h5>
</div>

Only one renders_one can be read. If you have more than one as shown below, you will win later (that is, hogehoge2 will be displayed).

Multiple renders_one example.slim


div
  = render Example4::Component.new do |c|
    = c.header(title: "hogehoge1") do
    = c.header(title: "hogehoge2")

Also, when calling multiple times with renders_many, you can pass an array in the form of posts.

div
  = render Example4::Component.new do |c|
    = c.posts([{title: "The first one"}, {title: "Second"}])

Lambda expression call

The case of transfer can also be written as follows. This is more convenient when you want to insert processing in between.

app/components/example4/component.rb


module Example4
  class Component < ViewComponent::Base
    include ViewComponent::SlotableV2

    renders_one :header, -> (title:) do
      HeaderComponent.new(title: title)
    end

    renders_many :posts, -> (title:) do
      #This is convenient when you want to change the title a little, or when you want to control the display contents by passing variables.
      ExamplePost::Component.new(title: title + '_hogehoge')
    end

    class HeaderComponent < ViewComponent::Base
      attr_reader :title

      def initialize(title:)
        @title = title
      end
    end
  end
end

Direct call

app/components/example5/component.rb


module Example5
  class Component < ViewComponent::Base
    include ViewComponent::SlotableV2

    renders_one :header
    renders_many :posts
  end
end

It is also possible to call it by defining it roughly as described above. This shape is simple if the components are well designed.

Caller.slim


div
  = render Example5::Component.new do |c|
    = c.header(title: "hogehoge")
    = c.posts([{title: "The first one"}, {title: "Second"}])

Inline component

It is also possible to create a component only with rb without writing a template.

app/components/example6/component.rb


module Example6
  class Component < ViewComponent::Base
    #The name call is set by default
    def call
      link_to 'link to root', root_path
    end

    # call_If you name it xxx, you can call it as a public method.
    def call_other
      link_to 'link to other', root_path
    end
  end
end

Caller.slim


div
  = render Example6::Component.new
  br
  / with_variant(:By specifying other), call_Calling other
  = render Example6::Component.new.with_variant(:other)

When you want to render or separate according to the conditions

It can be controlled by using render?. This makes it possible to exclude the display control logic from the View.

app/components/example7/component.rb


module Example7
  class Component < ViewComponent::Base
    def initialize(is_show:)
      @is_show = is_show
    end

    def render?
      @is_show == true
    end
  end
end

Caller.slim


div
  = render Example7::Component.new(is_show: true)
  = render Example7::Component.new(is_show: false)

Output result.html


<div><p>Only one is displayed</p></div>

Rendering pre-processing

Preprocessing can be done before rendering. I think it's better to do it with initialize, but this seems to be faster when looping multiple times.

app/components/example8/component.rb


module Example8
  class Component < ViewComponent::Base
    def before_render
      @title = 'Pre-rendered title'
    end

    def initialize; end
  end
end

When you want to pass a collection as a parameter

app/components/example/component.rb


module Example
  class Component < ViewComponent::Base
    #The parameter name passed for collections is hoge by default_component.hoge part of rb
    #If you want to change this with_collection_Must be named as parameter
    with_collection_parameter :title

    def initialize(title:)
      @title = title
    end
  end
end

Caller.slim


div
  - titles = ["hoge", "fuga"]
  = render Example::Component.with_collection(titles)

When you want to count the collection

app/components/example9/component.rb


module Example9
  class Component < ViewComponent::Base
    with_collection_parameter :title

    # _The name counter sets a count for the number of times the collection is looped.
    def initialize(title:, title_counter:)
      @title = title
      @counter = title_counter
    end
  end
end

Caller.slim


div
  - titles = ["hoge", "fuga"]
  = render Example9::Component.with_collection(titles)

Directory structure

Place various items under the app/components directory. From the viewpoint of making it easy to understand as a component, I think that it is better to create a directory for each component for app/components/example and put the necessary files in it.

Of course, there is no problem even if you put various things directly under app/components.

app/components
├── ...
├── example
|   ├── component.rb
|   ├── component.css
|   ├── component.html.slim
|   └── component.js
├── ...

//When calling, the folder name is considered as the namespace, so it will be in the following form
<%= render(Example::Component.new(title: "my title")) do %>
  Hello, World!
<% end %>

View Component test

The following is for rspec.

Configuration

It is necessary to set to generate convenient methods such as render_inline.

spec/rails_helper.rb


require "view_component/test_helpers"

RSpec.configure do |config|
  config.include ViewComponent::TestHelpers, type: :component
end

Test example

I think it's convenient because it makes it easier to write View tests.

require 'rails_helper'

RSpec.describe Example::Component, type: :component do
  it 'renders something useful' do
    render_inline(described_class.new(title: 'hoge'))
    assert_includes rendered_component, 'hoge'

   #When capybara is included, you can also use capybara's matcher
    assert_text('hoge')
  end
end

Component preview

You can see a preview of the component, like ActionMailer :: Preview. It seems to be useful when writing CSS individually.

The following is required as the initial setting.

config/application.rb


config.view_component.preview_paths << "#{Rails.root}/spec/components/previews"

The settings for viewing the preview are as follows. Just create a preview class and method and call it from there.

spec/components/previews/example_preview.rb


class TestComponentPreview < ViewComponent::Preview

  #Considering that it is a component, it seems better not to set layout
  #Is it set according to taste?
  layout false

  def default_title
    render(Example::Component.new(title: 'Test component default'))
  end
end

The above files can be accessed here. http://127.0.0.1:3000/rails/view_components


From here, we finally start talking about ViewComponent Reflex.


What is ViewComponentReflex in the first place?

I'm not sure about it, so I'm trying this. Roughly speaking, let's describe everything on the server side that changes the value with ajax.

Preparation

Since you need Redis in ActionCable, prepare Redis in some way. If you have completed various settings for ViewComponent, you do not need to do anything else.

How to use

app/components/example10/component.rb


module Example10
  class Component < ViewComponentReflex::Component
    def initialize
      @count = 0
    end

    def increment
      @count += 1
    end
  end
end

All you have to do is write refrex_tag and specify the method defined on the component side.

app/components/example10/component.html.slim


= component_controller do
  p
    = @count
  = reflex_tag :increment, :button, "Click"

Caller.slim


div
  = render Example10::Component.new

Only this is written, but the number is incremented by communicating with the server side.

view_component_reflex.gif

If you want to change to mouse enter etc. instead of click event, write as follows.

app/components/example10/component.html.slim


= component_controller do
  p
    = @count
  = reflex_tag "mouseenter->increment", :button, "Click"

Just hover your mouse cursor over it to increase the number. mouseenter.gif

When you want to move other elements

When component_controller is used, the variable key is automatically generated. By using this key, you can move the processing of other components.

parent.slim


= component_controller do
  div#loader
    - if @loading
p loading
  = reflex_tag :do_action, :button, "Click"
  / component_The key is automatically generated by the controller
  = render Child::Component.new(parent_key: key)

parent.rb


module Parent
  class Component < ViewComponentReflex::Component
    def initialize
      @loading = false
    end

    #Processing called by the child
    def update_loading
      @loading = !@loading
      
      #The process of updating a particular selector in a component
      # prevent_refresh!There is also a process that does not accept updates
      # refresh_all!Then, update all body elements
      refresh! '#loader'
    end
  end
end

child.rb


module Child
  class Component < ViewComponentReflex::Component
    def initialize(parent_key:)
      @parent_key = parent_key
    end

    def stimulate_parent
      #Hit the parent's process with stimulate
      # parent_The component is linked with the key
      stimulate("Parent::Component#update_loading", { key: @parent_key })
    end
  end
end

When you want to add an element to a collection

Implement separately for the parent element that holds the element collection itself and the child element that displays the element collection.

parent.rb



class MyUserModel
  attr_accessor :id, :name

  def initialize(id:, name:)
    @id = id
    @name = name
  end
end

module Parent

  class Component < ViewComponentReflex::Component

    def initialize(users:)
      @users = users
    end

    def add_user
      #Normally, I think that data is passed from the outside, but it is a sample.
      #It's hard to know how much to do in ViewComponent. There are times when you need to redisplay from data storage.
      @users.append(MyUserModel.new(id: @users.length + 1, name: "#{(@users.length + 1).to_s}Ro"))
    end
  end
end

child.rb


module Child
  class Component < ViewComponentReflex::Component
    #If you forget to define this, the argument will not be read properly \
    with_collection_parameter :user

    def initialize(user:)
      @user = user
    end

    #I get angry if I don't set this
    def collection_key
      @user.id
    end
  end
end

parent.slim


= component_controller do
  /Call the component (child) that displays the collection
  = render Child::Component.with_collection(@users)
  = reflex_tag :add_user, :button, "Increase Taro"

child.slim


/Just displaying
= component_controller do
  = @user.id
  = @user.name

Caller.slim


div
  = render Parent::Component.new(users: [MyUserModel.new(id:1, name:'1ro')])

All you have to do is apply these.

Miscellaneous feelings

I have some quirks, and there are still many things that are missing, but if it's a light service, I think it's a good idea to try it. It seems to be easy to use if it is reloaded after posting comments on the bulletin board etc. It is difficult who should update the data and so on. I don't know the best practice.

Recommended Posts

ViewComponentReflex A note I saw by touching
I read the readable code, so make a note
I tried JAX-RS and made a note of the procedure
I haven't understood after touching Spring Boot for a month