Rails, Hotwire, CableReady, and StimulusReflex are BFFs

Enforcing strict RESTful routes and controllers is perhaps the most impactful technique that influenced my usage of Ruby on Rails for the better. I cannot overstate how much I love traditional REST semantics and encourage their usage on every team that I have influence over.

Having said that, I also think rigidly applying this pattern to smaller and smaller use cases has diminishing returns. One example of a smaller use case is TurboFrames. TurboFrames are great and I use them along with their attendant REST semantics, but I try to be very thoughtful about how far I take this approach.

For example, libs like CableReady and Futurism can lazy load partials so unobtrusively that the notion of adhering to the formality of REST, with its attendant new routes, controllers, etc…, would be far too much ceremony for matching use cases.

One of the original goals of CableReady and StimulusReflex was to work seamlessly with traditional HTTP server rendered Rails apps (pre Hotwire) without requiring significant architectural changes or forcing a proliferation of new routes, controllers, or views/partials etc… We basically wanted a way to gradually introduce robust real-time and reactive behavior into traditional Rails apps with as little friction as possible. The idea being to allow people to leverage the work that had already been done rather than forcing a rethinking of the app. I view CableReady/StimulusReflex as as: REST + RPC sprinkles + async server triggered DOM behavior.

Hotwire, while very cool, introduces new concepts that impose a higher cognitive cost and forces you to rethink how to best structure a Rails app. I view Hotwire as: REST semantics for everything + async server triggered CRUD updates.

There are pros and cons to each approach. Hotwire has more obvious and strict conventions, while CableReady and StimulusReflex adhere more to Ruby’s philosophy of flexibility and expressiveness.

For me, using both Hotwire and CableReady + StimulusReflex techniques together is like “having my cake and eating it too.” Admitedly, this is a power move and requires some experience to know when to apply each approach.

FYI – There are some great conversations on the StimulusReflex Discord server about this stuff. We’d love it if you joined us.

Also, I should note how much I dislike the umbrella marketing term “Hotwire” as it forces a false dichotomy in this conversation. Both CableReady and StimulusReflex are designed to work well with Hotwire libs and even have hard dependencies on some of them.


Source link

Blog Demo using Rails 7 + Hotwire Rails + TailwindCss + Stimulus + @rails/request.js

Hi Devs, let’s get your hands dirty!

We will develop a demo blog to learn and understand how the new Ruby on Rails frameworks (Hotwire, Stimulus) work, in this project we will be using the beta version of Rails 7.0.2alpha and taking advantage of it to use Tailwindcss which can be installed from the beginning of the project.!

  • setup initial:
ruby: 3.0.2
rails: 7.0.2alpha
  • Create new project:
rails new blog --css tailwind
  • Generate scaffold of the blog post:
rails g scaffold post title
  • Install framework rails(ActionText):
rails action_text:install
  • Run migration in database:
rails db:create db:migrate
  • Config model, controller and views to add rich_textarea in posts:
# app/models/post.rb
class Post < ApplicationRecord
  validates :title, presence: true

  has_rich_text :content # add rich_text
end
# app/views/posts/_form.html.erb
<!-- .... -->
<!-- add field :content -->
<div class="my-5">
  <%= form.label :content %>
  <%= form.rich_text_area :content, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<!-- .... -->
# app/views/posts/_post.html.erb
<!-- .... -->
<!-- add field :content -->
 <p class="my-5">
   <%= @post.content %>
 </p>
<!-- .... -->
# app/views/posts/show.html.erb
<!-- .... -->
<!-- add field :content -->
 <p class="my-5 inline-block">
   <%= @post.content %>
 </p>
<!-- .... -->
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
# ...
 private
   def post_params
     params.require(:post).permit(:title, :content) # add content
   end
end
  • The idea is to create a SPA using Hotwire Rails, so let’s configure the blog’s index page:
# app/views/posts/index.html.erb

<div class="w-full">
  <div class="flex justify-between items-center">
    <h1 class="text-white text-lg font-bold text-4xl">Posts</h1>
    <%= link_to 'New post', new_post_path,
      class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium",
      data:  'turbo-frame': 'new_post' 
    %>
  </div>

  <%= turbo_frame_tag :new_post %>

  <div class="min-w-full">
    <%= turbo_frame_tag :posts do %>
      <%= render @posts %>
    <% end %>
  </div>
</div>

  • By clicking on the new post button we will render the new page to register the post:
<!-- link #app/views/posts/index.html.erb -->
<%= link_to 'New post', new_post_path,
      class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium",
      data:  'turbo-frame': 'new_post' 
%>

<!-- turbo-frame :new_post #app/views/posts/index.html.erb -->
<%= turbo_frame_tag :new_post %>
  • Let’s configure the blog’s new page:
<!-- app/views/posts/new.html.erb -->
<%= turbo_frame_tag :new_post do %>
  <div class="w-full bg-white p-4 rounded-md mt-4">
    <h1 class="text-lg font-bold text-4xl">New post</h1>

    <%= render "form", post: @post %>

    <%= link_to 'Back to posts', posts_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
  </div>
<% end %>

  • Now we are going to configure the CRUD actions using Turbo Stream, for that we must configure the posts controller, so let’s start:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  # ...
  def create
    @post = Post.new(post_params)

    respond_to do |format|
      if @post.save
        format.turbo_stream # add format turbo_stream
        format.html  redirect_to posts_path 
        format.json  render :show, status: :created, location: @post 
      else
        format.turbo_stream # add format turbo_stream
        format.html  render posts_path, status: :unprocessable_entity 
        format.json  render json: @post.errors, status: :unprocessable_entity 
      end
    end
  end

  def update
    respond_to do |format|
      if @post.update(post_params)
        format.turbo_stream # add format turbo_stream
        format.html  redirect_to posts_path, notice: "Post was successfully updated." 
        format.json  render :show, status: :ok, location: @post 
      else
        format.turbo_stream # add format turbo_stream
        format.html  render posts_path, status: :unprocessable_entity 
        format.json  render json: @post.errors, status: :unprocessable_entity 
      end
    end
  end

  def destroy
    @post.destroy
    respond_to do |format|
      format.turbo_stream # add format turbo_stream
      format.html  redirect_to posts_url, notice: "Post was successfully destroyed." 
      format.json  head :no_content 
    end
  end
  # ...
end
  • That done, now rails expects that we have new templates or pages for each action that has been added to the format.turbo_stream, so we should create one for each action (create, update, destroy):
<!-- add pages ->
<!-- app/views/posts/create.turbo_stream.erb -->
<!-- app/views/posts/update.turbo_stream.erb -->
<!-- app/views/posts/destroy.turbo_stream.erb -->
  • create.turbo_stream.erb

     <% if @post.errors.present? %>
       <%= notice_stream(message: :error, status: 'red') %>
       <%= form_post_stream(post: @post) %>
    <% else %>
       <%= notice_stream(message: :create, status: 'green') %>
    
       <%= turbo_stream.replace :new_post do %>
          <%= turbo_frame_tag :new_post %>
       <% end %>
    
       <%= turbo_stream.prepend 'posts', partial: 'post', locals:  post: @post  %>
    
     <% end %>
    
    • update.turbo_stream.erb
   <% if @post.errors.present? %>
     <%= notice_stream(message: :error, status: 'red') %>
     <%= form_post_stream(post: @post) %>
   <% else %>
     <%= notice_stream(message: :update, status: 'green') %>
     <%= turbo_stream.replace dom_id(@post), partial: 'post', locals:  post: @post  %>
   <% end %>
  • destroy.turbo_stream.erb
  <%= notice_stream(message: :delete, status: 'green') %>
  <%= turbo_stream.remove @post %>
  • To finish we need to add helpers to add and remove notifications and also render the form when you hear errors, so let’s add the helpers:
# app/helpers/posts_helper.rb
module PostsHelper
  NOTICE = 
    create: 'Post created successfully',
    update: 'Post updated successfully',
    delete: 'Post deleted successfully',
    error: 'Something went wrong'
  .freeze

  def notice_stream(message:, status:)
    turbo_stream.replace 'notice', partial: 'notice', locals:  notice: NOTICE[message], status: status 
  end

  def form_post_stream(post:)
    turbo_stream.replace 'form', partial: 'form', locals:  post: post 
  end
end

  • To add the notification we need to add a turbo frame:
<!-- app/views/layouts/application.html.erb -->
 <%= turbo_frame_tag :notice, class: 'w-full' do %>
 <% end %>

<!-- app/views/posts/_notice.html.erb -->
<p class="animate-pulse opacity-80 w-full py-2 px-3 bg-<%= status %>-50 mb-5 text-<%= status %>-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>

  • After we add the notifications to each action, it would be nice if it disappeared after a while, so I’ll create a route to be called just to remove the notification using @rails/request.js, so let’s do it:
# config/routes.rb

get '/notice', to: 'posts#clear_message'
  • First let’s configure the form, adding a controller stimulus to intercept the request, with this we’ll be able to call the route always after submitting a request in the form:
  <%= turbo_frame_tag dom_id post do %>
    <%= form_with(
      model: post, 
      id: 'form',
      class: "contents",
      html: 
        data:  controller: 'notice', action: 'submit->notice#clear' 
      
    ) do |form| %>

   <!-- fields  --- >

   <% end %>
<% end %>
import  Controller  from "@hotwired/stimulus"
import  FetchRequest  from "@rails/request.js"
// Connects to data-controller="notice"
export default class extends Controller 
  clear(event) 
    event.preventDefault()

    setTimeout(async () => 
      const request = new FetchRequest("get", '/notice',  responseKind: "turbo-stream" )
      await request.perform()
    , 5000)

    event.target.requestSubmit()
  


  • Now we’ll add action in posts controller:
class PostsController < ApplicationController
 # ... actions

 def clear_message
  respond_to do |format|
    format.turbo_stream
  end
 end
end
  • Finally, we’ll add the action template, which will always remove the notification:
<!-- app/views/posts/clear_message.turbo_stream.erb -->
<%= turbo_stream.replace 'notice' do %>
  <%= turbo_frame_tag :notice %>
<% end %>

Wow !
We’ve reached the end, I hope you managed to explain everything, if you have any questions just send me a message here!

source code: Link Github

Show, see you later!

Twitter: AlefOjeda
GitHub: nemubatuba




Source link