レシピIDリンク移行の話

こんにちは、2024年4月から、新卒でレシピ事業部プロダクト開発グループに所属している、張頌です。私が新卒入社して最初に取り組むこととなったのが、このブログで以前ご紹介したOne Experienceプロジェクトです。

このプロジェクトには、データ移行や多言語対応など、私が経験してこなかったタスクがたくさんありました。私が所属しているチームではスクラム開発によってプロジェクトを進めております。初めてこの体制で大きなプロジェクトに参加することは、大変でしたが面白い体験でした。私はこの中で、データ移行のレシピIDリンクの移行を担当いたしました。

私が最初にこのタスクを携わっていた時に、色々な理由でミスがありました。本稿はこのタスクの新人なりの振り返りをし、次にもし大きなプロジェクトに携わる時に、どう対処すれば問題を回避できるかという知見をまとめようと思います。

レシピIDリンクの移行について

日本版ではレシピに、決まったフォーマットでレシピのIDを書くと、レシピを表示する時にそのレシピIDを持つレシピページに飛ぶリンクが付けられました。以下の画像のように、例えばレシピの手順で「レシピID:12345のソース」と記法で書くと、「レシピID:12345」の部分がリンクになります。

事前調査によると、このようなレシピIDは日本版のレシピにおいて紹介文、材料、手順、コツ・ポイントなどのところにありました。

ところで今回、レシピのデータは日本版のデータベースからグローバル版のデータベースに移行しています。このときレシピのIDが変わるので、何もしないままだとリンク先の整合性が崩れてしまいます。このためレシピIDリンクについても修正が必要ですが、グローバル版のレシピのIDは移行してみないと決まりません。故に、先にレシピのデータを移行し、そのあとレシピIDリンクを置換することにしました。今回のタスクはリンク先となるレシピのIDを、グローバル版のIDに置換することです。

レシピIDリンク移行の振り返り

レシピIDリンク移行に取り組み始めた頃、私はまだ慣れていないことや知らないことが多く、あまり細かい調査はできていませんでした。その結果としていくつかの不具合を生んでしまいました。幸いOne Experienceのリリースの前に起こった不具合だったのでユーザーへの影響はなかったのですが、ここではそれらを振り返りたいと思います。

状態遷移の考慮漏れ

最初に取り組んだのは、日本版からグローバル版へ継続的にデータ移行している部分での置換です。データ移行を行っている部分のキャッチアップが難しかったので、コードを見ながら吸収しようと思いました。実際キャッチアップはすぐに終わり、タスクを始めた次の日にはpull requestを出しました。不安なところも色々ありましたが、コードを読みながら問題ないかを確認し、Approveをもらって、Deployしました。

そこで問題が発生しました。日本版でレシピを編集しても、グローバル版でレシピIDが変わらなかったんです。そこでデータ移行のテストファイルから、レシピ編集のシチュエーションをテストしている箇所を見つけて、シミュレートして試しました。Debugしてコードを追っていたら、レシピ編集の際には自分が実装を追加したところとは別の箇所のコードが動いていると気付きました。その後、丁寧にレシピ移行全体の状態遷移を考え、あらゆる状況を考慮して、テストでシミュレートしました。全遷移パターンで対応済みだと確信して、実装完了としました。

振り返ってみると、最初にコードをみた時に、レシピ編集についての実装があることには気付いていたことを思い出しました。このとき自分が実装したところだけで足りているのか不安で、周りの同僚には相談していましたが、実装に詳しい先輩には相談していませんでした。また、テストされている条件を網羅的に試して、できるだけ再現して漏れがないか確認した方が良かったと思います。

不可解なクエリ結果

データ移行の実装では、継続的なデータ移行とは別に、まとまったデータを一括でバッチ処理することでデータ移行を行う実装も存在しました。ここについてもレシピIDリンク移行を行う必要があったのですが、罠がありすぎて、難しかったです。いくつかご紹介します。

バッチ処理によって移行されてきたレシピに対して、後からレシピIDリンクの書き換えを行うことを考えます。どのレシピについて書き換えるべきか判断するための方法として、レシピの最終更新時刻を日本版とグローバル版で比較することで決めることとしました。ここで日本版の最終更新時刻はQueuery(きゅうり)という内製ツールでSQLクエリを実行して得ています。

以前にも似たような実装をした経験があったので、自信満々でpull requestを出して、早速Approveをもらって実行しました。検証環境で確かめて、何個か例を見ると書き変わったことを確認しました。

そこで、また問題が発生しました。データを調査したら、書き換わってないレシピIDが存在していると気付いたのです。そこでまた調査を始めまして、コードを読んで、色々バグが出そうなところにバグの仮説を立ててました。それでも結局見つからなかったので、ちょっと焦ったところもありまして、先輩とペアプロを始めました。

先輩と一緒に調査をしまして、そもそもQueueryから返ってくるレシピのリストに書き換わる対象レシピが入ってないのかもし��ないと考え始めました。しかし、手元で同じSQLクエリを実行した結果には対象レシピが入っていたので、結果が違うことはありえないなと最初は思っていました。ところが、実装の方の中間変数をstep by stepに見たら、確かに対象レシピが入ってないことがありました。何故そうなっているのか全く分からず、同僚と共に調査を続けました。最終的に、Queueryの場合と手元の場合でSQLクエリの中でのエスケープの挙動が異なっているのが原因と分かりました。

ダブルチェックの重要さ

レシピIDリンクのバッチ処理を実際に動かす前に、継続的な移行のときの失敗を思い出し、全体的なデータ検証をした方がいいと思いました。そこでレシピIDに関連するバグが出そうな仮説を立て、検証することにしました。そういえば私はアイデアをたくさん出すことが得意なので、既存のデータの不具合を結構見つけました。実行する前に見つかって良かったと思いました。

その後もいくつかの問題を切り抜けながら、レシピIDリンクを移行することができました。最終的に無事に移行できて良かったです。最初にミスがあり焦ったけど、難しい仕様があり、罠みたいなバグが難しいのがしょうがなく、失敗しても焦らず、相談してチャレンジしていくことが大事だと非常にわかりました。これもだんだん出来るようになったポイントかなと思いました。

次に活かすポイント

今回の振り返りは、おそらく以下の3つのパターンに分けられると思います。

ポイント1:未知の存在を認識し、詳しい人や前例の知恵を吸収する

アクションをとる前に、何を追加で確認しないとダメかを知らないままに、今ままでの知識だけで対応して、ミスる場合です。新卒の時に、特にOne Experienceのような大きいプロジェクトの中で、全面的に細かく教えてもらえる人は少ないので、結局個人判断の時が多いです。ある程度のミスは起こるものであり、教訓にすべきだと思いますが、そのような状況でもできることはあると思います。

たとえば知らないタスクをもらったとき、課題の理解が足りなく解像度が低いままでタスクを進めないことです。自信はいいことだと思いますが、新卒として理解したのは、完全理解との距離があることを認識すべきでした。しかもレビュワーも知らないことがあるので、その時、知らないことがあるかを確認するためのリサーチや、気軽に他の人に聞いていくことで、改善するだろうと思います。

ポイント2:裁量範囲を模索し、適切に先輩とシンクする

アクションをとるとき、大量な意思決定があるけど、個人の裁量でできないことを決めちゃった場合です。状態遷移の考慮漏れを起こしたときのように、過信によって問題が起こりえます。

これについてはアクションをとる時に、先輩との報告と相談が必要です。それだけではなく、先輩から助言をもらった時にも、課題と話を完全に吸収してから、議論していくことが大事です。要するに、アクションをとる前に先輩の言っていることを理解してから、自分が何をするつもりか報告し、目標と認識がシンクして初めて、アクションをとるべきだと思います。

ポイント3:未然に防ぐ、考慮漏れ確認をルーチン化する

実際に、新卒に限らず、しょうがなく、人間はミスすることが必ずある生き物なので、そのミスした経験を持って、2度目をないようにするのがいいと思います。そのために、確認ポイントを書面化したり、今回のミスのフィードバックを吸収したりできます。特に重要そうなものを他の人と共有しても良いでしょう。

そして、次に似たようなことがあったら、網羅的な確認をきちんと怠けず行うべきです。自分の変更の影響力を理解しつつ、ユーザーのためにも、考慮漏れ確認をルーチン化した方がいいと思います。

最後に

今回は、One Experienceプロジェクトの環境でのレシピIDリンク移行の時の自分なりの振り返りをしました。私がこのタスクを始めて、想定外の問題がたくさんでました。難しい仕様をもつタスクなので、うまくできなかったところがありました。この振り返りと経験を次に似たようなタスクをやる時に、できるだけ参考にしたいと考えています。

iOSアプリにおける複数リリースに跨った機能改善の開発事例紹介

レシピ事業部のHaurta (@0x746572616e79 )です。グローバルサービスとの統合プロジェクト(One Experienceプロジェクト)に伴いiOSアプリケーションもグローバルと日本で別々のアプリケーションを開発していた体制から一変して、グローバルのアプリケーションベースの開発(グローバル版)へ移行を進めました。

グローバルと日本で異なるアプリケーションを開発してきたため、同じクックパッドでも細かな挙動の違いが見られます。気になる挙動がないかどうかチームで何度もウォークスルーを重ねた結果、レシピエディターやプロフィール設定画面で使われるフォトピッカーの挙動が問題として浮上しました。

フォトピッカーの改善を重要なタスクとして取り組むことにしましたが、フォトピッカーに限らずOne Experienceプロジェクトが始まってからはグローバル版のコードベースを読むところからのスタートになるため、このタスクの完了にどのくらい時間がかかるのか推測しづらい状態でした。

一般的に大きなタスクを小さく分割することは行われますが、具体的な分割方法や進行の手法については、慣れや経験だったり試行錯誤が必要です。

この記事では、フォトピッカーの改善を通じて、タスク分割とプロジェクトの進め方について幾つかの学びがあったため、どのようにタスクを分解し進めていったのかを紹介します。

フォトピッカー

クックパッドにおいてフォトピッカーは非常に重要な機能の一つで、旧日本版、グローバル版ともにフォトピッカーは標準のPHPickerViewControllerを使用せず、何種類か目的と体験にあった自前のフォトピッカーを実装していました。

旧日本版では、ユーザーがレシピに写真を追加する際、フォトピッカーが最初に起動し、そこからカメラスクリーンに遷移できるボタンが配置されていました。これに対し、グローバル版では、カメラスクリーンが最初に起動し、カメラスクリーンにフォトピッカーへの遷移ボタンが設置されていました。

手順写真を載せようとした時に表示される画面

グローバル版のカメラ機能はシンプルなため、多くのユーザーが外部のカメラアプリを利用するだろうと考えられます。この状況では、画像をアップロードするたびにカメラスクリーンが起動するのは不便です。また、X(旧Twitter)やInstagramといったSNSアプリでは、フォトピッカーを先に表示し、内部にカメラアクセスの導線を設けるのが一般的です。そこで、グローバル版もフォトピッカーを最初に起動する流れに改善することになりました。

問題解決への道筋を立てる

グローバル版は各機能がどのように実装されているか、画面の構造、実装を理解するところから改善を進めます。当初は画面の入れ替えだけで済むと考えていましたが、コードリーディングを進めるうちにかなりの工数が必要であることが判明しました。

グローバル版はCoordinator Patternを用いた画面遷移を採用しており、フォトピッカーにはカメラへの遷移を設置する必要がありました。この時、概算で10時間以上の工数がかかる可能性を感じつつも、どこから着手すれば良いのか具体的な見積もりができていません。

グローバル版のアーキテクチャ

レシピ事業部はスクラム開発を採用しており、タスクの優先順位や範囲を決定するためにも、工数の見積もりが必要です。非常に正確である必要はありませんが、大雑把過ぎる見積もりでは不透明なため優先順位判断が難しくなります。

改善タスクには破壊的な変更が含まれており、複数スプリントにわたって開発が必要な場合モバイル特有の問題を考える必要があります。モバイルアプリはスプリントごとにリリース*1をしているため、未完成の機能が露出しないようにしなければなりません。そのため、mainブランチへ細かく変更を加えていくのか、開発ブランチを事前に用意しまとめてmainブランチに取り込む方法を採用すべきかどうかを検討しました。

mainブランチに直接細かな変更を積み重ねる方法では、コミットの粒度をコントロールしやすく、各変更の影響範囲を小さく保つことができます。また、最新のmainに追加していくため、安定性を確保しやすいです。しかし、開発中の機能や未完成の変更がリリースで露出してしまわないよう厳重な管理が求められます。

一方、開発ブランチを用いた方法では、mainブランチに影響を与えることなく機能を追加、変更することができます。ただ、開発ブランチでの作業が進む中でmainブランチにも並行して別の変更が加えられることがほとんどで、コンフリクトのリスクが高くなります。また、mainマージをするタイミングで全ての差分をチェックする必要があり、レビューコストの増加につながります。アルファリリースでは、開発ブランチを用いて変更を積み重ねていましたが、mainブランチと大きな差分が発生してしまい、コミットの統合時に多大な工数がかかることがありました。

最終的にはmainブランチへ変更を積み上げていく方法で進めることを決めました。

リリー��を跨ぐ機能開発

mainブランチに変更を積み上げていくと決めましたが、ではどう破壊的な変更を閉じておくべきでしょうか?

Feature Toggle

グローバル版にはFeature Toggle *2 が用意されています。A/Bテストなどで広く使われており、代表的なサービスとしてFirebase Remote Config *3 が有名です。詳しくは取り上げませんが、One Experience向けの機能をグローバル版へ実装、展開する際にもFeature Toggleが利用されていました。遷移先の切り替えや、UITableViewで表示するコンテンツの出し分けなど色々なユースケースでの利用ができます。

class Coordinator: InteractorDelegate {
  ...
  func interactorWantsToInsertStepAttachments() {
    if appContext.featureToggle.supports(.フォトピッカーを先に起動する) {
      startPhotoPickerFlow()
    } else {
      startCameraFlow()
    }
  }
}

今回のフォトピッカー改善タスクではCoordinatorの画面遷移処理にてFeature Toggleを利用し出し分けを行う方法を採用しました。別の方法としてFeature Toggleがオンのときにはフォトピッカーを、オフのときにはカメラスクリーンを初期表示する単一のCoordinatorを定義する方法も検討しましたが、実装の簡素化と管理のしやすさを考えると独立したCoordinatorを用意する方が適していると今回は判断しました。

デバッグメニューからFeatureToggleの切り替えができる

開発中は端末内のFeatureToggleを強制的に有効化しフォトピッカーを立ち上げ、本番環境では以前のカメラを立ち上げることで意図せず開発中の実装がユーザーの方に見えてしまう事故を防ぐことができます。

タスクと仕様を整理する

Feature Toggleによって全ての問題が解決するかというと、そうではありません。Feature Toggleで分岐先を変えるのが良さそうだというのはわかりましたが、依然としてどれくらいの工数がかかるかわからないためタスクと仕様を整理する必要があります。遷移先の画面をFeature Toggleで分岐できるようになったので全く新しい画面を定義して表示することもできますが、また0からフォトピッカーやカメラスクリーンを実装するのは二度手間でその分工数がかかってしまいます。手間を省くためにも可能な限り、既存のコンポーネントや実装は使い回しをしたいです。

(左)改善前のエラー表示、(右)期待するエラー画面

ただ、既存のフォトピッカーは使い回すには少々難しい実装になっていました。たとえばデバイス内の写真にアクセスするための権限をリクエストするPHPhotoLibrary.requestAuthorizationはカメラスクリーンを表示しているタイミングで実行していたり、権限がない時は専用のエラーコンポーネントをフォトピッカーで表示するのではなく、UIAlertControllerを利用したエラー表示を行っていました。このまま画面を入れ替えてしまうと、フォトピッカーのアクセスを拒否したときカメラスクリーンへの導線がなくなってしまい体験が悪いです。エラー画面の追加、アクセス権限をリクエストするタイミング調整など、新しいCoordinatorを定義する前にフォトピッカー自体の機能追加が必要だということがわかりました。

少しずつ取り組むべきタスクが明確になり、優先順位もまた見えてきました。新しいCoordinatorを追加する前にフォトピッカーの機能追加を進める必要がありますが、フォトピッカーにカメラセルを追加する、エラー画面を追加するといった変更はそれぞれ独立したタスクとして進めることができます。また、新しいCoordinatorを追加するのもFeatureToggleを利用することでユーザーの目に触れることなく開発を進められることがわかりました。

ここまで調査時間含めて3h程度で整理、設計をし、やっとある程度正確に工数を見積もることができました。これで他Epicイシューとの優先順位を決めタスクとして進めることができるようになります。

まとめ

フォトピッカー改善タスクを例にタスクの分解とリリースを跨いだ機能開発の事例についてご紹介しました。特に、大きなタスクを細かく分解するためにモバイル特有のリリース制約を考慮し、対応が必要な細かいタスクをリストアップする進め方は他のタスクにも応用できると思います。

また、今回は遷移先のコントロールにFeature Toggleを活用し、比較的綺麗な設計を実現しましたが、Feature Toggleの運用には注意点も存在します。実際に、Feature Toggleを使ったアプローチがうまくいかなかったケースもいくつか経験しています。機会があれば、Feature Toggle単体のお話もできればと思います。

この記事で紹介した方法や考え方が、皆さんの今後のアプリケーション開発において何かしらの参考になれば幸いです。

*1:レシピ事業部では1スプリントを一週間で回しているため週次リリースを行っています

*2:一般的にはFeature Flagとも呼ばれている(https://martinfowler.com/articles/feature-toggles.html

*3:https://firebase.google.com/products/remote-config?hl=ja

ちょっと複雑なサイドバーをHotwireで簡単に作りたい

こんにちは、レシピ事業部プロダクト開発グループの渡邉(@taso0096)です。 クックパッドは最近、One Experienceというプロジェクトによって日本版とグローバル版のシステムが統合されました。 どちらのシステムもRailsで実装されているという点は同じですが、統合先となったグローバル版ではHotwireが使われていました*1。そのため、One Experience関連の開発ではHotwireが積極的に活用されています。本記事ではそんなHotwireの多くの機能が使われたデスクトップ版のサイドバーについてご紹介します。

デスクトップ版で表示されるサイドバー

ちょっと複雑なサイドバー

One Experienceに伴い、グローバル版にもともと存在したUIのまま移行するのではなく、いくつか画面構成の変更を入れる事になりました。特にデスクトップ版においては、自分のコンテンツにより素早くアクセスできるようにするためにサイドバーの導入が決まりました。

このサイドバーでは、一般的なナビゲーションメニューのほかに「きろく」と呼ばれるコンテンツを表示しています。「きろく」とはユーザの保存レシピや投稿レシピなどを整理するための機能です。また、「きろく」に保存したレシピはユーザーさんの手で、フォルダを作成して分類できます。このとき、レシピやフォルダの数が多いユーザーさんでも充分素早く表示させたいです。更に、サイドバーの外でレシピが保存されたとき、リロードせずともサイドバーの中の表示も追従する必要があります。以上を整理すると、作りたいものは以下のようなものということになります。

  • コンテンツが多く読み込みに時間がかかる場合を考慮して非同期で読み込む
  • フォルダが多い場合でも全てのフォルダを読み込める
  • レシピの保存やフォルダの作成などの操作時に表示を同期する

これらの要件はHotwireを使えば簡単に実装することができます。ここではHotwireの各機能を軽く説明しつつ、それぞれどういった実装をしたのかご紹介します。

Turbo Frames

Turbo Framesとはページ全体をリロードせずに部分的な更新を可能にするための機能です。部分更新したい箇所をTurbo Framesのタグで囲うことで、そのタグ内の部分更新が行われます。また、iframeのようにURLを指定することで全く別のページを埋め込むことも可能です。この場合はタグの中身が最初にレンダリングされ、その後に非同期で別のページが読み込まれます。

今回実装したサイドバーでは、非同期でコンテンツを読み込むためにTurbo Framesを利用しました。そのためにTurbo Framesで表示される専用ページを新規作成し、図の赤枠内で読み込むようにしました。この赤枠の部分ではローディングが最初に表示され、ページの読み込み後に非同期でコンテンツが読み込まれます。

Turbo Framesの範囲

多くの場合、Rails側で実装してある既存のページのロジックなどをそのまま流用して簡単に埋め込めるというのがTurbo Framesのメリットかと思います。一方、この例では新規ページをわざわざ作成しました。これはフォルダのページネーション実装をシンプルにするためで、Stimulus で実装しています。詳細は後ほど解説します。

なお、Turbo Framesはlazy loadingも可能であり、今回も設定しています。サイドバーはデスクトップ版ではスクロール位置などに関係なく常に表示されるものですが、スマホ版だとそうではないためです。

ページ遷移に伴うリセット

Turbo Framesによって非同期でのコンテンツの取得が実現できました。しかし、このままではページ遷移するたびにコンテンツの再取得が行われてしまいます。場合によってはそれでも問題ないですが、今回はページネーションによって読み込まれたフォルダの情報がリセットされることを避けたいと考えました。そうでなくともページ遷移する度にサイドバーがローディングによって一瞬使えなくなることはかなり不便だと思います。

そこでdata-turbo-permanent属性というものを使用しました*2。以下のようにこの属性が付与されたDOMはページ遷移時にもDOMが維持されます。

<div data-turbo-permanent>sidebar</div>

これにより一度読み込んだTurbo Framesのコンテンツは通常の画面遷移ではリセットされなくなります。これを再度読み込むにはブラウザのリロードやJavaScriptによる再読み込みの処理が必要になります。

レスポンス

Turbo Framesが取得するHTMLはページに埋め込まれる部分だけではありません。layoutテンプレート��そ使用されませんがActionViewでレンダリングされたページ全体がレスポンスとして返されます。そのため、ActionViewの中の一要素のみを埋め込みたいといった場合はそれ以外のレスポンスは破棄されてしまいます。おおよそのレスポンスは以下のようになっており、ブラウザ側のHotwireランタイムで解釈されて画面に埋め込まれます。

<html>
  <head></head>
  <body>
    <div>破棄される要素</div>
    <turbo-frame id="dom_id">埋め込みたい要素</turbo-frame>
  </body>
</html>

多少の無駄は生じてしまいますが、上でも書いたように既存のページをほぼそのまま流用可能であるというメリットの方が大きいと考えます。なお、今回は新規ページを作成しActionView全体が埋め込まれる形にしたため、そもそも破棄される要素は存在しません。

Turbo Streams

Turbo Streamsとはリアルタイムでのデータ更新を簡単にするための機能です。ページに対してDOMの追加・変更・削除などが可能であり、複数箇所を同時に更新することもできます。今回はレシピを新規で保存した際のレシピ数の更新や、フォルダ自体の編集を反映するために使用しました。

レスポンス

Turbo Framesと違い、Turbo Streamsではページ全体ではなく、差分のみをサーバーからレスポンスします。DOMをどのように扱うかについてはTurbo Streamsのアクションによって指示されます。例えばユーザが新規でレシピをフォルダに追加した場合を考えます。この場合は画面の赤枠部分が全て更新されます。

Turbo Streamsによって更新される要素

この時に返されるレスポンスはおおよそ以下のようになります。

<turbo-stream action="replace" target="dom_id">保存ボタンのHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">ドロップダウンのHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">「きろく」の「すべて」のHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">「きろく」の「保存済み」のHTML</turbo-stream>
<turbo-stream action="prepend" target="dom_id">「きろく」の「新規フォルダ」のHTML</turbo-stream>
<turbo-stream action="replace" target="dom_id">通知のHTML</turbo-stream>

このレスポンスをHotwireランタイムが解釈して画面の更新が行われます。

リダイレクト

サイドバーの同期を実装するにあたって、これまでは画面の更新が不要だったいくつかの既存のリクエストのformatをHTMLからTurbo Streamsに置き換える必要がありました。基本的に問題なく置き換えることが可能でしたが、リダイレクトが必要な場合は少し工夫する必要がありました。

例えばユーザがフォルダのページからそのフォルダを削除した場合を考えます。この場合はサイドバーの「きろく」から対象のフォルダのDOMを削除した上で「きろく」のトップにリダイレクトするという仕様になっています。これはデフォルトのアクションでは対応できないため、以下のようなリダイレクトのためのカスタムアクションを追加することで対応しました。

Turbo.StreamActions.redirect = function () {
  Turbo.visit(this.target)
}

View側では通常のアクションとほとんど同じように呼び出すことが可能です。

<%= turbo_stream.action :redirect, path %>

実際のレスポンスは以下のようになり、Hotwireランタイムで解釈されてリダイレクトが実行されます。

<turbo-stream action="redirect" target="path"></turbo-stream>

カスタムアクションは任意のJavaScriptを簡単に実行できるためかなり便利な機能です。しかし、だからと言ってデフォルトアクションで対処可能な機能に対してカスタムアクションを作成してしまうとコードの一貫性が失われてしまうため注意が必要です。

Stimulus

StimulusとはHTMLとJavaScriptを適切に切り離して書くための枠組みです。これによってHTMLだけを見た時にどんな挙動をするのかわかりやすくしたり、コード自体の再利用性を高めるメリットがあります。CSSがHTMLのclass属性を介して紐づいているように、StimulusではHTMLのデータ属性を介して任意のJavaScriptによる操作を可能にします。このJavaScriptはcontrollerという単位で分けられており、controllerのメソッドをDOMのライフサイクルやイベントをフックに実行するというのが主な機能です。これによって再利用しやすいJavaScriptになるような仕組みになっています。今回はページネーションのための無限スクロールとアクティブ状態の更新のために使われました。

無限スクロール

Turbo Framesのセクションでフォルダのページネーションについて解説しましたが、より使いやすくするために無限スクロールに対応します。グローバル版には元々無限スクロール用のcontrollerが実装されていたためこれをそのまま使用しました。実装としてはシンプルで、スクロールイベントを監視し閾値を超えたら次のページを非同期通信で読み込むというものです。Stimulusのtargetsとvaluesの機能を使ってリストの中身はどのDOMなのか、次のページのURLは何なのかといった情報を管理しています。

なお、Hotwireにおける無限スクロールの実装としてTurbo Framesの遅延読み込みを活用したものもあります。この場合は自分ではJavaScriptを一切書かずに無限スクロールの実装が可能です。ただし、単純なページネーションにcontrollerを登録だけすれば済むStimulusと比較すると、Viewに少し手を入れる必要がある点には注意が必要です。今回は便利に使える既存実装があったのでTurbo Framesによる無限スクロールをあえて採用するようなことはしませんでした。

アクティブ状態の更新

Turbo Framesのセクションで説明したように、サイドバーは画面遷移によるリセットを回避するためにdata-turbo-permanent属性が指定されています。しかし、これによって現在のページに応じて「きろく」のリンクのアクティブ状態を更新する機能が壊れてしまいました。これに対処するにはJavaScriptによってページ遷移を検出してアクティブ状態を更新する必要があります。作成したcontrollerの中身自体は単純なものですが、メソッドを呼び出すためのイベントの指定だけ少し特殊になっています。Stimulusではdata-action属性を使ってどのイベントにフックしてメソッドを実行するか指定することができます。このとき、基本的には属性が指定されたDOMに対するイベントを参照しますが、ページ遷移のようなグローバルのイベントフックしたい場合は@documentのようなsuffixを指定することで対応できます*3

<div data-action="turbo:visit@document->controller#method"></div>

まとめ

Hotwireを活用したちょっと複雑なサイドバーの実装についてご紹介しました。Hotwireの仕組みを利用することでインタラクティブなUIのためのJavaScriptをほとんど書かずに主要な機能の実装ができたかと思います。個人的には元々はNext.jsを書いていたこともありJavaScriptは好きですが、Railsを書く上でHotwireはかなり良くできた仕組みだと感じています。Hotwireはまだ使い始めたばかりの技術ですので、新しい知見が溜まったらまた共有したいと思います。