[Rails7] Turbo Streamsで実現するリアルタイムブックマーク機能

## はじめに
Rails7では、`Turbo/Hotwire`や`RailsUJS`の導入により、従来の方法とは異なる手法で非同期通信や動的なUI更新が可能になりました。本記事では、ブックマーク機能を実装する際に直面した問題とその解決策、成功の要因について詳しく解説します。

## 実装環境
- 投稿のモデル:`Tweet`モデル
- ユーザー情報のモデル：`User`モデル
- 中間モデル：`Bookmark`モデル

## 実装の背景
### Rails 7 への移行
Rails7では`Turbo/Hotwire`がデフォルトで有効になり、リンクやフォームの送信方法が従来と異なります。そのため、今までと同じ実装方法ではうまくいかない場合がありました。
`RailsUJS`の設定やインポートが重要な役割を果たしており、これが正しく読み込まれていないと`AJAX`リクエストが正しく機能しませんでした。

## ブックマーク機能の概要
ユーザーは各ツイートに対してブックマークを行い、ブックマークされたツイートの一覧を確認できるようにします。
非同期リクエスト(AJAX)により、<b>ページ全体のリロードなし</b>でアイコンの切替が可能になります。

## 主な実装工程
### ルーティングの設定
`config/routes.rb`において、ツイートごとにブックマーク作成・削除のルートを設定しました。
```ruby:routes.rb
resources :tweets do
  resource :bookmark, only: %i[create destroy]
end
```
この設定により、`POST /tweets/:tweet_id/bookmark`と`DELETE /tweets/:tweet_id/bookmark`のルートが生成されます。

### コントローラーの実装
`BookmarksController`を作成し、ブックマークの作成と削除のアクションを実装しました。
```ruby:bookmarks_controller.rb
class BookmarksController < ApplicationController
    before_action :authenticate_user!
  
    def create
      @tweet = Tweet.find(params[:tweet_id])
      current_user.bookmark(@tweet)
      respond_to do |format|
        format.js   # create.js.erb をレンダリング
        format.html { redirect_to request.referer, notice: "ブックマークしました" }
      end
    end
  
    def destroy
      @tweet = Tweet.find(params[:tweet_id])
      current_user.unbookmark(@tweet)
      respond_to do |format|
        format.js   # destroy.js.erb をレンダリング
        format.html { redirect_to request.referer, notice: "ブックマークを解除しました" }
      end
    end
  end
```

### モデルの設定
`Userモデル`に、ブックマーク用の関連付けとメソッドを実装しました。
```ruby:user.rb
class User < ApplicationRecord
  
  has_many :bookmarks, dependent: :destroy
  has_many :bookmark_tweets, through: :bookmarks, source: :tweet

  def own?(object)
    id == object.user_id
  end

  def bookmark?(tweet)
    bookmarks.exists?(tweet_id: tweet.id)
  end

  def bookmark(tweet)
    bookmarks.create(user_id: id, tweet_id: tweet.id) unless bookmark?(tweet)
  end  

  def unbookmark(tweet)
    bookmarks.find_by(tweet_id: tweet.id)&.destroy
  end
end
```
これにより、`current_user.bookmark_tweets`でブックマーク済みのツイートを一覧取得可能です。
Tweetモデルのアソシエーションも忘れないようにしましょう。

### ビューの実装と`AJAX`更新
各ツイート表示の部分で、ブックマークの状態に応じてボタンを切り替えるために、部分テンプレートを利用しました。
以下のViewファイルを作成する
- tweets/_tweet.html.erb 
- 未ブックマーク用（bookmarks/_bookmark.html.erb）
- ブックマーク済み用（bookmarks/_unbookmark.html.erb）

```ruby:tweets/index.html.erb 
<%= render 'tweet', tweet: tweet %> #追記
```

```ruby:_tweet.html.erb 
<div class="tweet">
    <% if user_signed_in? %>
    <p><%= tweet.body %></p>
    <% if current_user.bookmark?(tweet) %>
        <%= render 'bookmarks/unbookmark', tweet: tweet %>
    <% else %>
        <%= render 'bookmarks/bookmark', tweet: tweet %>
    <% end %>
    <% end %>
</div>
```
```ruby:_bookmark.html.erb
  <%= link_to tweet_bookmark_path(tweet),
              method: :post,
              remote: true,
              data: { turbo: "false" },
              id: "js-bookmark-button-for-tweet-#{tweet.id}" do %>
    <i class="fa-regular fa-bookmark"></i>
  <% end %>
```
```ruby:_unbookmark.html.erb
  <%= link_to tweet_bookmark_path(tweet),
              method: :delete,
              remote: true,
              data: { turbo: "false" },
              id: "js-bookmark-button-for-tweet-#{tweet.id}" do %>
    <i class="fa-solid fa-bookmark"></i>
  <% end %>
```
:::note warn
注意
今回、ブックマークアイコンはfontawesomeを使用しています。そのため、未登録の方はサインアップからお願いします。
:::
https://fontawesome.com/

登録後、「12a4b6...」というような文字列が並んでいますと思います。これは<b>あなただけの秘密の暗号（APIキーと言います）なので、流出しないように気を付けましょう！</b>
まず、fontawesome を使う事前準備として、`application.html.erb`の`head`タグの中に`Add Your Kit's Code to a Project`に書かれているコードを追加します。
![スクリーンショット 2025-02-16 12.55.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3700876/f9dec2e4-52ae-4d3a-9664-4aa30ce393b7.png)


#### AJAXレスポンス用のJSテンプレート
```ruby:bookmarks/create.js.erb
document.getElementById("js-bookmark-button-for-tweet-<%= @tweet.id %>").innerHTML = "<%= j(render 'bookmarks/unbookmark', tweet: @tweet) %>";
```
```ruby:bookmarks/destroy.js.erb
document.getElementById("js-bookmark-button-for-tweet-<%= @tweet.id %>").innerHTML = "<%= j(render 'bookmarks/bookmark', tweet: @tweet) %>";
```
### `JavaScript`と`RailsUJS`の設定
`app/javascript/application.js`では、`RailsUJS`を正しく読み込むことが重要でした。
application.jsがこのようになっているか確認してください
```javascript:application.js
// Rails UJS を先に読み込む
import Rails from "@rails/ujs"
Rails.start()

// 次に Turbo を読み込む
import "@hotwired/turbo-rails"
import "controllers"

// もしTurboの自動駆動が邪魔する場合は無効化（ただし、data-turbo="false"をリンクに追加していれば不要な場合もあります）
Turbo.session.drive = false
```
RailsUJS が正しく動作していなければ、リンククリック時に正しい HTTP メソッドが送信されず、`GET`リクエストになってしまいます。

:::note warn
ここまでやってGetメソッドになる
その場合、importmapが正しく使えていない可能性があります。以下を確認してみてください。
:::
①`config/importmap.rb`に以下の記述があるかを確認する
```ruby:config/importmap.rb
pin "@rails/ujs", to: "https://ga.jspm.io/npm:@rails/ujs@7.0.4/lib/assets/compiled/rails-ujs.js"
```
②`application.html.erb`の`head`タグに以下の記述があるか確認する
```ruby:application.html.erb
<%= javascript_importmap_tags %>
```


## 課題と成功の要因
### 課題
- Rails7の新技術 (Turbo/Hotwire) の導入
    - これにより、従来の`RailsUJS`の挙動が変わり、リンククリック時の HTTP メソッド指定に苦労しました。
- JavaScriptモジュールの読み込み
- `@rails/ujs`が読み込まれていなかったため、非同期リクエストが正しく動作せず、`GET`リクエストになってしまう問題が発生しました。

### 成功の要因
- `RailsUJS`の正しい設定
    - `import Rails from "@rails/ujs"`を適切に設定し、`RailsUJS`を有効化することで、非同期リクエストが正常に動作するようになりました。
- Turbo の影響を無効化
    - リンクに`data: { turbo: "false" }`を追加し、`Turbo`がリンクの`HTTP`メソッドを上書きしないようにしたこと。
- `AJAX`更新のためのJSテンプレートの実装
    - `create.js.erb`と`destroy.js.erb`を作成し、ユーザーがリロードせずにアイコンの状態を即時更新できるようにしました。

## ブックマーク一覧ページの実装
さらに、ユーザーがブックマークしたツイートだけを一覧表示するページも実装できます。
### ルーティング
`config/routes.rb`において、ツイートごとにブックマーク作成・削除のルートを設定しました。
```ruby:routes.rb
resources :tweets do
    resource :bookmark, only: %i[create destroy]  
    collection do
      get 'bookmarks'
    end
  end
```
この設定により、`POST/tweets/:tweet_id/bookmark`と`DELETE /tweets/:tweet_id/bookmark`のルートが生成されます。
### コントローラーの実装
```ruby:tweets_controller.rb
def bookmarks
  @tweets = current_user.bookmark_tweets
end
```

### ビュー (app/views/tweets/bookmarks.html.erb)
```ruby:bookmarks.html.erb
<h1>ブックマークしたツイート一覧</h1>

<% if @tweets.present? %>
  <% @tweets.each do |tweet| %>
    <%= render 'tweet', tweet: tweet %>
  <% end %>
<% else %>
  <p>まだブックマークしたツイートはありません。</p>
<% end %>
```

## まとめ
今回の成功要因は以下の通りです.
- Rails 7 の新技術（Turbo/Hotwire）の理解と適切な設定
- Rails UJS の正しい読み込みと設定
- AJAX リクエストに対する JS テンプレートの実装
- ルーティングやモデル、ビューの連携を細かく調整してトラブルシュートしたこと

これらを実装することで、リロードなしで状態更新できる動的なブックマーク機能を実現できました。

## 参考記事
https://qiita.com/subaru-hello/items/78d7364e11d2e67a0670

https://boku-boc.hatenablog.com/entry/2020/11/18/224554