CallMarx.dev

Tutorial: Rails7, Tailwind e Hotwire - Parte 2

19-12-2021 11 minutos de leitura.
Hotwire Turbo Logo

Na parte anterior deste tutorial eu expliquei como customizar e utilizar o Tailwind sem uma linha sequer de CSS e JavaScript. Agora vou abordar um pouco sobre o pacote Hotwire Turbo.

Objetivo Geral

A meta é desenvolver (e aprender) utilizando Rails 7, esbuild, Tailwind e Hotwire (Turbo e Stimulus), mas meu foco será mais sobre o pacote Hotwire e como ele pode nos ajudar. Conforme avanço nos estudos e na implementação, vou complementando este tutorial. Por enquanto temos:

O pano de fundo é uma aplicação estilo Kanban, com um quadro em que podemos incluir, ver, editar e excluir os cards/tarefas e isso ser persistido simultaneamente via websockets para todas as sessões abertas da aplicação. Todo código esta disponível neste repositório. Note que incluí algumas branches que representam as partes abordadas aqui.

Etapa 2 - Hotwire Turbo

Nesta etapa explico sobre a proposta desta ferramenta e implemento o modo render turbo_stream juntamente com o broadcast via ActionCable. O resultado desta etapa eu dividi em duas partes: a branch blog-part-2.1, em que uso apenas o turbo_stream sem broadcast, e a final com broadcast na branch blog-part-2.2.

Conceitualmente: Turbina o que?

Consultando a introdução do Handbook, e fazendo uma tradução livre, conceitualmente temos um pacote que

Agrupa uma série de ferramentas para criar aplicações web velozes, modernas e de aprimoramento progressivo, sem muito JavaScript.

Talvez a tradução de Progressive Enhancement - “aprimoramento progressivo” - não soe muito claro. Criado em 2003, trata-se basicamente de uma estratégia de design, de arquitetura web, que enfatiza o carregamento progressivo da página, priorizando o conteúdo principal (HTML) e distribuindo os demais (CSS, JavaScript, HTML adicionais etc) em outras camadas de apresentação/carregamento.

Continuando minha tradução livre sobre as diretrizes do Hotwire Turbo

Oferece uma alternativa mais simples contra a predominância das estruturas do lado do cliente, das quais colocam toda a lógica no front-end, confinando o lado do servidor da aplicação a ser pouco mais do que uma API JSON.

Este é o trecho que mais me chamou atenção, me conquistou, me incentivou a estudar e querer utilizar isso. A “predominância das estruturas do lado do cliente” é algo que vem me chamando atenção nos últimos anos, parece que de repente toda área do back-end foi “confinada” à API JSON. Porém, com todo dinamismo que hoje em dia uma página web precisa, como processamento de dados de acordo com o comportamento do usuário e da navegação, isso acabou sendo feito pelo lado cliente, front-end, via JavaScript com bibliotecas como jQuery. Como determinadas lógicas desse processamento devem estar protegidas no back-end, não podendo serem completamente expostas no front-end, ou seja, não podendo serem totalmente feitas no lado cliente da aplicação, nos deparamos eventualmente com “espelhamentos da lógica em ambos os lados. E as diretrizes continuam exatamente nesse sentido.

Com Turbo você permite que o servidor entregue HTML diretamente (…). Você não lidera mais com espelhamento da lógica em ambos os lados da aplicação, permeados via JSON. Toda lógica reside no servidor e o navegador lida apenas com o HTML final.

É o conceito de HTML-Over-The-Wire - Hotwire. 🤓

Turbo Frame VS. Turbo Stream

Algo que me confundiu bastante no começo foi a demora em entender a diferença entre Turbo Frame e Turbo Stream, pois tratam-se de abordagens diferentes. Felizmente eu encontrei essa tabela publicada pelo The Pragmatic Programmers.

---------------------------------------------------------------------------+---------------------------------------------------------------------------+
                Turbo Frames                                 |                     Turbo Streams                                                       |
----------------------------------------------------------------------------+--------------------------------------------------------------------------+
Altera apenas um elemento do DOM por requisição.             |  Altera inúmeros elementos do DOM por requisição.
Consegui apenas atualizar o elemento interno do HTML.        |  Pode concatenar ou preceder um novo elemento, além de atualizar ou remover um elemento.
Afeta apenas elementos dentro da tag turbo-frame com DOM ID. |  Afeta qualquer tipo de elemento HTML desde que tenha um DOM ID para ser referenciado.
É "ativado" na requisição dentro do elemento turbo-frame.    |  Ativado também via ActionCable/broadcasts.

OBS: Não pretendo utilizar o Turbo Frames neste projeto. Talvez eu repense isso para aproveitar a funcionalidade de lazily load, mas se for o caso adicionarei um post separado sobre isso.

Começando apenas com Turbo Stream

Para explicar separadamente o modo render turbo_stream eu incluí o código desta subparte na branch blog-part-2.1.

No ChoresController que geramos com rails generate scaffold na etapa anterior deste tutorial, por padrão o Rails incluiu múltiplos formatos de renderização, no caso HTML e JSON. Como incluímos gem "turbo-rails" no Gemfile, temos acesso também à renderização via Turbo Stream, bastando adicionar format.turbo_stream dentro do bloco respond_to do |format|. Fazendo isso para os métodos create e destroy, temos em app/controllers/chores_controller.rb:

# file app/controllers/chores_controller.rb of blog-part-2.1 branch
class ChoresController < ApplicationController
  ...
  def create
    ...
    respond_to do |format|
      if @chore.save
        format.turbo_stream # include this
        format.html { redirect_to @chore, notice: "Chore was successfully created." }
        format.json { render :show, status: :created, location: @chore }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @chore.errors, status: :unprocessable_entity }
      end
    end
  end
  ...
  def destroy
    @chore.destroy
    respond_to do |format|
      format.turbo_stream # include this
      format.html { redirect_to chores_url, notice: "Chore was successfully destroyed." }
      format.json { head :no_content }
    end
  end
  ...
end

Para esse tipo de renderização também precisamos de arquivos dedicados em app/views, como o temos para HTML e JSON. Sendo assim, temos o seguinte app/views/chores/create.turbo_stream.erb:

<!-- file app/views/chores/create.turbo_stream.erb of blog-part-2.1 branch -->
<%= turbo_stream.append "chores", partial: "chores/chore", locals: { chore: @chore } %>
<%= turbo_stream.replace "chore_form", partial: "chores/form", locals: { chore: Chore.new } %>

Como queremos ver a aplicação disso sem recarregar uma página inteira, ou seja, poder renderizar apenas uma parte da página, o faremos no index de chores. Por isso usamos os métodos turbo_stream.append e turbo_stream.replace acima apontados para o DOM ID da página, ou seja, respectivamente os primeiros argumentos "chores" e "chore_form" devem estar presentes na página completamente renderizada por app/views/chores/index.html.erb. Sendo isso assim precisamos incluir id="chores" na <div> que envolve a listagem dos chores:

<!-- file app/views/chores/index.html.erb  of blog-part-2.1 branch -->
<div ...>
  <div ...>
    <div id="chores" ...> <!-- include id="chores" for turbo_stream.append -->
      <% @chores.each do |chore| %>
        <%= render "chore", chore:chore %>
      <% end %>
    </div>
  </div>
  <div ...>               <!-- don't include id="chore_form" here!         -->
    <%= render "form", chore: @chore %>
  </div>
</div>

Agora, para id="chore_form", não podemos incluir na <div> que envolve <%= render "form", chore: @chore %> pois o método turbo_stream.replace substitui completamente o elemento e como o fazemos substituindo pelo partial view app/views/chores/_form.html.erb essa <div> seria apagada, sendo assim devemos incluir id="chore_form" no próprio arquivo form:

<!-- file app/views/chores/_form.html.erb  of blog-part-2.1 branch -->
<%= form_with(model: chore, id: "chore_form") do |form| %>
  ...
<% end %>

Agora, para excluir um chore é ainda mais simples, temos o seguinte app/views/chores/destroy.turbo_stream.erb

<!-- file app/views/chores/destroy.turbo_stream.erb of blog-part-2.1 branch -->
<%= turbo_stream.remove dom_id(@chore) %>

Da mesma forma, devemos incluir o DOM ID, mas no caso um específico para cada chore, por isso o dom_id(@chore). Além disso, também precisamos editar o <button> do ícone de exclusão para apontar para o método destroy do controller, sendo assim em: app/views/chores/_chore.html.erb

<!-- file app/views/chores/_chore.html.erb of blog-part-2.1 branch -->
<div id="<%= dom_id(chore) %>" ...> <!-- include dom_id(chore) for turbo_stream.remove -->
  <div ...>
    ...
    <div ...>
      ...
      <%= button_to chore_path(chore), method: :delete, class: ... do %> <!-- make button point to delete method -->
        <svg ...>
          ...
        </svg>
      <% end %>
      ...

Agora, ao remover um chore, também teremos uma renderização parcialmente que remove o HTML do chore excluído sem recarregar toda a página. O resultado final desta subparte, ao criar ou excluir um chore em http://localhost:3000/chores, é o seguinte: Turbo-Stream Add

OBS: Note que não há um carregamento total da página a cada inclusão ou exclusão, a página é recarregada parcialmente via fetch.

Porém, se você abrir duas janelas de http://localhost:3000/chores e incluir ou excluir chores em uma delas verá que as alterações são feitas apenas na janela que está manipulando isso, não haverá persistência em todas as sessões. Para isso precisamos fazer via ActionCable com broadcast.

Aplicando broadcast

Aqui corresponde a parte final desta etapa do tutorial. A subparte anterior foi apenas para explicar o uso isolado do render turbo_stream, como objetivo é uma aplicação estilo Kanban pretendo utilizar majoritariamente esse render juntamente com broadcast daqui para frente. O código completo desta etapa está na branch blog-part-2.2.

Para aplicarmos as alterações dos chores em todas as sessões utilizaremos o Turbo::StreamsChannel que vem com a gema turbo-rails, podendo ser chamado diretamente no model ou no controller dependendo da abordagem que desejar. A ideia aqui é que estaremos implementando as renderizações parciais em um padrão Publish/Subscribe. Por trás dos panos estaremos usando o Active Jobs para “publicar” (publish) a renderização parcial do turbo_stream de maneira assíncrona e o Action Cable para entregar essas atualizações aos “assinantes” (subscribers).

No caso, nossos “assinantes” são todas as sessões abertas de http://localhost:3000/chores e podemos mapear isso usando o helper turbo_stream_from. Sendo assim, em app/views/chores/index.html.erb, temos:

<!-- file app/views/chores/index.html.erb  of blog-part-2.2 branch -->
<%= turbo_stream_from "chores" %>
<div ...>
  ...
</div>

Como disse antes, podemos incluir Turbo::StreamsChannel no model ou controller para cobrir as modificações de create, update e delete, mais especificamente através dos métodos .broadcast_append_to, .broadcast_replace_to, .broadcast_remove_to entre outros. Se incluirmos isso no model através de Active Record Callbacks, como after_create_commit, toda vez que o model for alterado iremos disparar “publicações” de renderização parcial. Como por enquanto eu quero disparar as alterações feitas apenas através de requisições do usuário - não quero que rails db:seed dispare isso, por exemplo - optei por incluir no controller. Sendo assim em app/controllers/chores_controller.rb, temos:

# file app/controllers/chores_controller.rb of blog-part-2.2 branch
class ChoresController < ApplicationController
  before_action :set_chore, only: %i[ show edit update destroy ]
  after_action :broadcast_insert, only: %i[create]
  after_action :broadcast_remove, only: %i[destroy]

  ...

  private
    ...
    def broadcast_insert
      return if @chore.errors.any?
      Turbo::StreamsChannel.broadcast_append_to(
        "chores",
        target: "chores",
        partial: "chores/chore",
        locals: { chore: @chore }
      )
    end

    def broadcast_remove
      return unless @chore.destroyed?
      Turbo::StreamsChannel.broadcast_remove_to(
        "chores",
        target: ActionView::RecordIdentifier.dom_id(@chore)
      )
    end
end

Como os métodos privados broadcast_insert e broadcast_remove acima já fazem a inserção e exclusão dos chores não precisamos mais fazer isso nas views *.turbo_stream.erb que criamos na subparte anterior, por isso para essa parte final eu excluí a view app/views/destroy.turbo_stream.erb e mantive app/views/chores/create.turbo_stream.erb, com apenas:

<!-- file app/views/chores/create.turbo_stream.erb of blog-part-2.2 branch -->
<%= turbo_stream.replace "chore_form", partial: "chores/form", locals: { chore: Chore.new } %>

OBS: Não sei se você notou, mas não estamos “publicando” o replace do form via ActionCable como as outras renderizações parciais. Mantive aqui o turbo_stream para sessão corrente apenas, o que faz total sentido já que não precisamos limpar o form nas outras sessões, inclusive se fizermos isso poderemos apagar o form de um outro usuário que o está preenchendo e não submeteu ainda.

Pronto! Basta subir o projeto com bin/dev e acessar em mais de uma janela http://localhost:3000/chores, você deve obter este resultado final: Turbo-Stream with Broadcast

Nice Michael Scott - gif