Hotwire et ViewComponent sont dans un bateau… Épisode 1 : Turbo Stream
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 (postdans l’exemple ci-dessus) ;#to_key, qui retourne :- un tableau de valeur unique, pour la seconde partie du DOM ID (
42dans l’exemple ci-dessus) ; - ou
nilquand on ne peut pas déterminer de valeur unique, générant le préfixenew_.
- un tableau de valeur unique, pour la seconde partie du DOM ID (
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.
Découvrez comment nous épurons l’écriture de composants dynamiques avec Stimulus.
-
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. ↩
infoPiiaf