La SPA tombe à l’eau ! Un nouveau projet nous a donné l’occasion de faire émerger une belle articulation entre Hotwire et ViewComponent pour apporter la fluidité d’une front-end application sans tirer toute la complexité d’un cadriciel JavaScript et de son écosystème, avec des conventions mutualisées pour favoriser le DRY. Commençons par Turbo Stream.

Nous ne détaillerons pas ici le fonctionnement de Hotwire (et plus spécifiquement de son versant Turbo Stream) ni de ViewComponent. Leurs guides respectifs sont de qualité et vous offriront une bonne introduction si vous n’y êtes pas familiers. Cet article se concentrera sur les mécanismes que nous avons mis en place pour fiabiliser notre routine de développement avec ces deux bibliothèques. Pour illustrer nos propos, considérons le composant-exemple ci-dessous :

$ bundle exec rails g component occurrences/call_lists/attendances/form_component attendance --sidecar --stimulus
      create  app/components/occurrences/call_lists/attendances/form_component.rb
      invoke  rspec
      create    spec/components/occurrences/call_lists/attendances/form_component_spec.rb
      invoke  stimulus
      create    app/components/occurrences/call_lists/attendances/form_component/form_component_controller.js
      invoke  locale
      create    app/components/occurrences/call_lists/attendances/form_component/form_component.yml
      invoke  slim
      create    app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim

Vous découvrirez dans cet article comment nous facilitons l’usage de composants remplaçables atomiquement avec Turbo Stream.

Une méthode pour les gouverner tous

Peut-être connaissez-vous déjà la méthode #dom_id ? Elle est généralement utilisée avec des ActiveRecord pour générer par convention un identifiant unique compatible avec la spécification HTML. La documentation de Rails donne les exemples ci-après :

dom_id(Post)             # => "new_post"
dom_id(Post.new)         # => "new_post"
dom_id(Post.find 42)     # => "post_42"

Ce que l’on sait moins, c’est que n’importe quel objet peut être passé à #dom_id, sous réserve de répondre à deux méthodes :

  • #model_name, qui retourne un objet qui répond à #param_key, pour la première partie du DOM ID (post dans l’exemple ci-dessus) ;
  • #to_key, qui retourne :
    • un tableau de valeur unique, pour la seconde partie du DOM ID (42 dans l’exemple ci-dessus) ;
    • ou nil quand on ne peut pas déterminer de valeur unique, générant le préfixe new_.

Nous allons donc nous servir de cela pour générer un DOM ID pour chacun de nos ViewComponent ; avec force convention, évidemment.

require "ostruct"

class ApplicationComponent < ViewComponent::Base
  def model_name
    OpenStruct.new param_key: self.class.name.gsub("::", "--")
  end

  def to_key
    nil
  end
end

Prenons donc notre composant-exemple et redéfinissons #to_key ainsi :

class Occurrences::CallLists::Attendances::FormComponent < ApplicationComponent
  def initialize(attendance:)
    @attendance = attendance
  end

  def to_key
    [@attendance.id]
  end
end

Chaque instance de composant sait désormais calculer un identifiant unique en fonction du contexte avec lequel il est construit :

irb> dom_id(Occurrences::CallLists::Attendances::FormComponent.new(attendance: Attendance.find(5)))
"Occurrences--CallLists--Attendances--FormComponent_5"
irb> dom_id(Occurrences::CallLists::Attendances::FormComponent.new(attendance: Attendance.new))
"new_Occurrences--CallLists--Attendances--FormComponent"

Et dans les Turbo Stream les lier.

À partir de là, une belle opportunité s’offre à nous, que nous allons résumer en trois lignes de code :

/ app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim
div id=dom_id(self)
  / […]
# Dans une action de contrôleur quelconque
item = Occurrences::CallLists::Attendances::FormComponent.new(attendance: @attendance)
render turbo_stream: turbo_stream.replace(item)

Et un résultat dans votre navigateur :

<turbo-stream action="replace" target="Occurrences--CallLists--Attendances--FormComponent_5">
  <template>
    <!-- BEGIN app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim -->
    <div id="Occurrences--CallLists--Attendances--FormComponent_5">[…]</tr>
    <!-- END app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim -->
  </template>
</turbo-stream>

Regardez attentivement, la valeur <turbo-stream target> est identique à la valeur <div id> : un composant remplaçable par lui-même, avec un DOM ID calculé automagiquement. Cette convention codée joue un rôle central dans la cohérence entre vue et contrôleur, grâce à l’appel implicite de #dom_id par les méthodes de Turbo Stream comme #replace.

Quelle différence ?

   def update
     if @attendance.update(attendance_params)
       call_list = @attendance.call_list
-      call_list_id = "Occurrences--CallList_#{@call_list.id}"
-      render turbo_stream: turbo_stream.replace(call_list_id, partial: "occurrences/call_list", locals: { call_list: })
+      whole_call_list = Occurrences::CallListComponent.new(call_list:)
+      render turbo_stream: turbo_stream.replace(whole_call_list)
     else
-      form_id = "Occurrences--CallLists--Attendances--FormComponent_#{@attendance.id}"
-      render turbo_stream: turbo_stream.replace(form_id, partial: "occurrences/call_lists/attendances/form", locals: { attendance: @attendance }), status: :unprocessable_content
+      form = Occurrences::CallLists::Attendances::FormComponent.new(attendance: @attendance)
+      render turbo_stream: turbo_stream.replace(form), status: :unprocessable_content
    end
  end

Vous remarquerez que, dans l’ancienne version, il était nécessaire de savoir reconstruire les DOM ID des éléments qui devaient être remplacés ? Tout comme il fallait connaître le chemin vers le bon partial à regénérer, et identifier les variables locales à passer ? Dans la nouvelle version, vous avez juste à instancier le bon composant avec les arguments auto-documentés dans son initialize et à le passer à Turbo Stream. Merci #dom_id.

Nous savons maintenant mettre à jour très localement des bouts de notre interface en nous protégeant d’un écueil récurrent avec Turbo Stream : la duplication et dispersion des DOM ID entre vues et contrôleurs. Plus jamais nous ne casserons nos tests (ou pire, notre application) à cause d’une faute de frappe dans un DOM ID mal tapé ou modifié d’un côté mais pas de l’autre1.

Et nous pouvons aller plus loin grâce à la mise en place de ces conventions, pour apporter encore plus de dynamisme dans nos applications grâce à Stimulus, en limitant là-encore les erreurs liées à la saisie multiple des identifiants.

  1. Sans parler du fait qu’il n’y a plus à réfléchir à comment nommer ses DOM ID, partager et documenter les standards d’équipe, les conventions étant maintenant outillées par le code lui-même.