Ruby on Rails 7 : Hotwire et Turbo pour des applications réactives

Guide complet sur Hotwire et Turbo dans Rails 7. Apprenez à créer des applications réactives sans écrire de JavaScript avec Turbo Drive, Frames et Streams.

Guide Hotwire et Turbo pour Ruby on Rails 7

Rails 7 a révolutionné le développement web en intégrant Hotwire par défaut. Cette stack permet de créer des applications hautement réactives sans écrire une ligne de JavaScript custom. Turbo Drive, Turbo Frames et Turbo Streams remplacent les approches SPA traditionnelles par une philosophie "HTML over the wire".

Pourquoi Hotwire ?

Hotwire réduit drastiquement la complexité frontend. Plus besoin de React ou Vue pour des interfaces dynamiques : le serveur envoie du HTML prêt à l'emploi, et Turbo gère les mises à jour du DOM automatiquement.

Comprendre l'architecture Hotwire

Hotwire se compose de trois technologies complémentaires qui travaillent ensemble pour offrir une expérience utilisateur fluide sans la complexité habituelle des frameworks JavaScript.

Turbo Drive accélère la navigation en interceptant les clics sur les liens et les soumissions de formulaires. Au lieu de recharger toute la page, seul le contenu du <body> est remplacé, préservant le contexte JavaScript et CSS.

Turbo Frames décomposent les pages en sections indépendantes. Chaque frame peut être mise à jour séparément, permettant des interactions ciblées sans affecter le reste de la page.

Turbo Streams permettent des mises à jour en temps réel via WebSocket ou en réponse aux requêtes HTTP. Huit actions CRUD sont disponibles pour manipuler le DOM de manière déclarative.

ruby
# Gemfile
# Installation de Turbo Rails (inclus par défaut dans Rails 7+)
gem 'turbo-rails'

Pour les projets existants, l'installation se fait en une commande.

bash
# installation.sh
# Installation de Turbo dans un projet Rails existant
rails turbo:install

# Vérifie que l'import JavaScript est présent
cat app/javascript/application.js
# Devrait contenir : import "@hotwired/turbo-rails"

Configuration initiale de Turbo Drive

Turbo Drive est activé par défaut dans Rails 7. Toute navigation devient automatiquement "turbo" sans configuration supplémentaire. Le comportement peut être personnalisé via des attributs data.

erb
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <meta name="turbo-cache-control" content="no-preview">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>
  <body>
    <!-- Barre de progression pendant les navigations -->
    <div class="turbo-progress-bar"></div>
    <%= yield %>
  </body>
</html>

Turbo Drive peut être désactivé sur des liens ou formulaires spécifiques quand nécessaire.

erb
<!-- app/views/pages/examples.html.erb -->
<!-- Lien avec Turbo activé (par défaut) -->
<%= link_to "Dashboard", dashboard_path %>

<!-- Désactiver Turbo pour un lien spécifique -->
<%= link_to "Télécharger PDF", export_path, data: { turbo: false } %>

<!-- Désactiver Turbo pour un formulaire (upload de fichier par exemple) -->
<%= form_with model: @document, data: { turbo: false } do |f| %>
  <%= f.file_field :attachment %>
  <%= f.submit "Uploader" %>
<% end %>

<!-- Turbo avec confirmation -->
<%= link_to "Supprimer", item_path(@item),
    data: { turbo_method: :delete, turbo_confirm: "Êtes-vous sûr ?" } %>
Cache Turbo Drive

Turbo Drive met en cache les pages visitées. L'attribut data-turbo-track="reload" sur les assets force un rechargement complet si les fichiers CSS/JS changent.

Turbo Frames pour les mises à jour ciblées

Les Turbo Frames permettent de définir des zones de la page qui se mettent à jour indépendamment. Chaque frame possède un identifiant unique et ne réagit qu'aux réponses contenant un frame correspondant.

erb
<!-- app/views/messages/index.html.erb -->
<h1>Messages</h1>

<!-- Frame pour la liste des messages -->
<turbo-frame id="messages_list">
  <div id="messages">
    <%= render @messages %>
  </div>

  <%= link_to "Charger plus", messages_path(page: @next_page) %>
</turbo-frame>

<!-- Frame pour le formulaire de création -->
<turbo-frame id="new_message">
  <%= render "form", message: Message.new %>
</turbo-frame>

La réponse du serveur doit contenir un frame avec le même identifiant pour que la mise à jour fonctionne.

erb
<!-- app/views/messages/_message.html.erb -->
<!-- Partial pour un message individuel -->
<turbo-frame id="<%= dom_id(message) %>">
  <div class="message">
    <p><%= message.content %></p>
    <span class="metadata">
      Par <%= message.author.name %> - <%= time_ago_in_words(message.created_at) %>
    </span>

    <!-- Ces liens restent dans le frame -->
    <%= link_to "Éditer", edit_message_path(message) %>
    <%= button_to "Supprimer", message_path(message), method: :delete %>
  </div>
</turbo-frame>

Chargement différé avec Turbo Frames

Les frames peuvent charger leur contenu de manière asynchrone grâce à l'attribut src. Le contenu initial est affiché pendant le chargement.

erb
<!-- app/views/dashboard/show.html.erb -->
<h1>Tableau de bord</h1>

<!-- Statistiques chargées de manière asynchrone -->
<turbo-frame id="stats" src="<%= dashboard_stats_path %>">
  <div class="loading-placeholder">
    <p>Chargement des statistiques...</p>
  </div>
</turbo-frame>

<!-- Notifications chargées quand visibles (lazy loading) -->
<turbo-frame id="notifications"
             src="<%= notifications_path %>"
             loading="lazy">
  <p>Chargement des notifications...</p>
</turbo-frame>

<!-- Activité récente avec actualisation périodique -->
<turbo-frame id="recent_activity"
             src="<%= activity_path %>"
             data-controller="auto-refresh"
             data-auto-refresh-interval-value="30000">
  <%= render "activity/placeholder" %>
</turbo-frame>

Le contrôleur répond avec une vue contenant uniquement le frame demandé.

ruby
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
  def stats
    @user_count = User.count
    @message_count = Message.count
    @active_today = User.where("last_seen_at > ?", 24.hours.ago).count

    # Rendu partiel pour le frame
    render partial: "dashboard/stats"
  end
end
erb
<!-- app/views/dashboard/_stats.html.erb -->
<turbo-frame id="stats">
  <div class="stats-grid">
    <div class="stat-card">
      <h3><%= @user_count %></h3>
      <p>Utilisateurs</p>
    </div>
    <div class="stat-card">
      <h3><%= @message_count %></h3>
      <p>Messages</p>
    </div>
    <div class="stat-card">
      <h3><%= @active_today %></h3>
      <p>Actifs aujourd'hui</p>
    </div>
  </div>
</turbo-frame>

Prêt à réussir tes entretiens Ruby on Rails ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Turbo Streams pour les mises à jour en temps réel

Turbo Streams offrent huit actions pour manipuler le DOM : append, prepend, replace, update, remove, before, after et morph. Ces actions peuvent être déclenchées en réponse HTTP ou via WebSocket.

ruby
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @message = current_user.messages.build(message_params)

    respond_to do |format|
      if @message.save
        # Réponse Turbo Stream pour ajouter le message
        format.turbo_stream do
          render turbo_stream: [
            turbo_stream.prepend("messages", @message),
            turbo_stream.update("message_count", partial: "messages/count"),
            turbo_stream.replace("new_message", partial: "messages/form",
                                 locals: { message: Message.new })
          ]
        end
        format.html { redirect_to messages_path, notice: "Message créé" }
      else
        format.turbo_stream do
          render turbo_stream: turbo_stream.replace(
            "new_message",
            partial: "messages/form",
            locals: { message: @message }
          )
        end
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @message = Message.find(params[:id])
    @message.destroy

    respond_to do |format|
      # Suppression avec animation
      format.turbo_stream { render turbo_stream: turbo_stream.remove(@message) }
      format.html { redirect_to messages_path, notice: "Message supprimé" }
    end
  end
end

Templates Turbo Stream dédiés

Pour des réponses plus complexes, un template .turbo_stream.erb offre plus de flexibilité.

erb
<!-- app/views/messages/create.turbo_stream.erb -->
<!-- Ajoute le nouveau message en haut de la liste -->
<%= turbo_stream.prepend "messages" do %>
  <%= render @message %>
<% end %>

<!-- Met à jour le compteur -->
<%= turbo_stream.update "message_count" do %>
  <span><%= Message.count %> messages</span>
<% end %>

<!-- Réinitialise le formulaire -->
<%= turbo_stream.replace "new_message" do %>
  <%= render "form", message: Message.new %>
<% end %>

<!-- Affiche une notification flash -->
<%= turbo_stream.prepend "flash_messages" do %>
  <div class="flash flash-success" data-controller="auto-dismiss">
    Message envoyé avec succès !
  </div>
<% end %>
Format de réponse

Les réponses Turbo Stream doivent avoir le content-type text/vnd.turbo-stream.html. Rails le gère automatiquement avec respond_to et le format turbo_stream.

Broadcasting en temps réel avec Action Cable

Turbo Streams brillent vraiment avec le broadcasting WebSocket. Les utilisateurs reçoivent les mises à jour instantanément sans polling.

ruby
# app/models/message.rb
class Message < ApplicationRecord
  belongs_to :room
  belongs_to :author, class_name: "User"

  # Broadcast automatique après création
  after_create_commit do
    broadcast_append_to(
      room,
      target: "messages",
      partial: "messages/message",
      locals: { message: self }
    )
  end

  # Broadcast après mise à jour
  after_update_commit do
    broadcast_replace_to(
      room,
      target: dom_id(self),
      partial: "messages/message",
      locals: { message: self }
    )
  end

  # Broadcast après suppression
  after_destroy_commit do
    broadcast_remove_to(room, target: dom_id(self))
  end
end

La vue s'abonne au stream correspondant avec le helper turbo_stream_from.

erb
<!-- app/views/rooms/show.html.erb -->
<h1><%= @room.name %></h1>

<!-- Abonnement au stream WebSocket -->
<%= turbo_stream_from @room %>

<!-- Container pour les messages -->
<div id="messages">
  <%= render @room.messages.order(created_at: :asc) %>
</div>

<!-- Formulaire de nouveau message -->
<%= form_with model: [@room, Message.new], id: "new_message" do |f| %>
  <%= f.text_area :content, placeholder: "Votre message..." %>
  <%= f.submit "Envoyer" %>
<% end %>

Broadcast asynchrone pour les opérations lourdes

Pour éviter de bloquer les requêtes, le broadcast peut être effectué en arrière-plan.

ruby
# app/models/report.rb
class Report < ApplicationRecord
  belongs_to :user

  after_create_commit :generate_async

  private

  def generate_async
    GenerateReportJob.perform_later(self)
  end
end
ruby
# app/jobs/generate_report_job.rb
class GenerateReportJob < ApplicationJob
  queue_as :default

  def perform(report)
    # Simulation d'une opération longue
    report.update!(status: "processing")

    # Notifie l'utilisateur du début
    report.broadcast_replace_to(
      report.user, :reports,
      target: dom_id(report),
      partial: "reports/report"
    )

    # Génération du rapport
    result = ReportGenerator.new(report).generate
    report.update!(content: result, status: "completed")

    # Notifie l'utilisateur de la fin
    report.broadcast_replace_to(
      report.user, :reports,
      target: dom_id(report),
      partial: "reports/report"
    )
  end
end

Patterns avancés avec Turbo

Formulaire inline avec édition

Un pattern courant consiste à remplacer du contenu statique par un formulaire d'édition inline.

erb
<!-- app/views/tasks/_task.html.erb -->
<turbo-frame id="<%= dom_id(task) %>">
  <div class="task">
    <span class="task-content"><%= task.content %></span>
    <div class="task-actions">
      <%= link_to "Éditer", edit_task_path(task) %>
      <%= button_to "Supprimer", task_path(task), method: :delete,
          data: { turbo_confirm: "Supprimer cette tâche ?" } %>
    </div>
  </div>
</turbo-frame>
erb
<!-- app/views/tasks/edit.html.erb -->
<turbo-frame id="<%= dom_id(@task) %>">
  <%= form_with model: @task do |f| %>
    <%= f.text_field :content, autofocus: true %>
    <div class="form-actions">
      <%= f.submit "Sauvegarder" %>
      <%= link_to "Annuler", task_path(@task) %>
    </div>
  <% end %>
</turbo-frame>

Par défaut, les liens dans un frame restent dans ce frame. L'attribut data-turbo-frame permet de cibler un autre frame ou la page entière.

erb
<!-- app/views/search/_results.html.erb -->
<turbo-frame id="search_results">
  <ul>
    <% @results.each do |result| %>
      <li>
        <!-- Ce lien navigue dans la page entière, pas dans le frame -->
        <%= link_to result.title, result_path(result),
            data: { turbo_frame: "_top" } %>
      </li>
    <% end %>
  </ul>
</turbo-frame>
erb
<!-- app/views/layouts/_sidebar.html.erb -->
<aside>
  <!-- Ce frame charge son contenu et met à jour le main -->
  <turbo-frame id="sidebar_nav" target="main_content">
    <%= render "navigation" %>
  </turbo-frame>
</aside>

<main>
  <turbo-frame id="main_content">
    <%= yield %>
  </turbo-frame>
</main>
Debugging Turbo

En développement, ouvrez la console JavaScript et tapez Turbo.setProgressBarDelay(0) pour voir immédiatement la barre de progression. Turbo.session.drive = false désactive Turbo Drive temporairement.

Gestion des erreurs et états de chargement

Une bonne UX nécessite de gérer les états de chargement et les erreurs réseau.

erb
<!-- app/views/shared/_form_with_loading.html.erb -->
<%= form_with model: @model,
    data: {
      controller: "form-loading",
      action: "turbo:submit-start->form-loading#disable turbo:submit-end->form-loading#enable"
    } do |f| %>
  <%= yield f %>

  <button type="submit" data-form-loading-target="submit">
    <span data-form-loading-target="text">Sauvegarder</span>
    <span data-form-loading-target="spinner" class="hidden">
      <svg class="animate-spin h-5 w-5"><!-- spinner SVG --></svg>
    </span>
  </button>
<% end %>
app/javascript/controllers/form_loading_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["submit", "text", "spinner"]

  disable() {
    this.submitTarget.disabled = true
    this.textTarget.classList.add("hidden")
    this.spinnerTarget.classList.remove("hidden")
  }

  enable() {
    this.submitTarget.disabled = false
    this.textTarget.classList.remove("hidden")
    this.spinnerTarget.classList.add("hidden")
  }
}

Gestion des erreurs serveur

ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  rescue_from ActiveRecord::RecordNotFound do |exception|
    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: turbo_stream.replace(
          "main_content",
          partial: "shared/not_found"
        )
      end
      format.html { render "errors/not_found", status: :not_found }
    end
  end
end

Optimisation des performances

Quelques techniques pour des applications Turbo performantes.

ruby
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  # Évite les requêtes N+1 avec eager loading
  def index
    @messages = Message.includes(:author, :room)
                       .order(created_at: :desc)
                       .page(params[:page])
  end

  # Cache fragment pour les éléments statiques
  def show
    @message = Message.find(params[:id])
    fresh_when @message
  end
end
erb
<!-- app/views/messages/_message.html.erb -->
<!-- Cache au niveau du partial -->
<% cache message do %>
  <turbo-frame id="<%= dom_id(message) %>">
    <div class="message">
      <%= message.content %>
      <small>Par <%= message.author.name %></small>
    </div>
  </turbo-frame>
<% end %>

Conclusion

Hotwire et Turbo transforment le développement Rails en éliminant la complexité des frameworks JavaScript traditionnels. Turbo Drive accélère la navigation, Turbo Frames permettent des mises à jour ciblées, et Turbo Streams offrent des capacités temps réel puissantes.

Checklist pour bien démarrer avec Hotwire

  • ✅ Utiliser Rails 7+ pour avoir Hotwire intégré par défaut
  • ✅ Comprendre les trois composants : Drive, Frames et Streams
  • ✅ Identifier les zones de la page qui bénéficient de Turbo Frames
  • ✅ Utiliser respond_to avec format.turbo_stream dans les contrôleurs
  • ✅ Implémenter le broadcasting pour les fonctionnalités temps réel
  • ✅ Combiner avec Stimulus pour les interactions JavaScript simples

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Cette approche "HTML over the wire" permet de construire des applications modernes et réactives tout en gardant la simplicité et la productivité qui font la force de Ruby on Rails. Le résultat : moins de code à maintenir, des performances excellentes, et une expérience développeur optimale.

Tags

#ruby on rails
#hotwire
#turbo
#turbo frames
#turbo streams

Partager

Articles similaires