Top View


Author Yuhei Okazaki

Railsにいいね機能をReactで実装する

2020/11/04

いいね機能とは

本記事で作るいいね機能は、以下のようなものです。

FacebookやTwitterにあるような気に入った投稿に好意的な気持ちを示すボタンを作る、ということです。 クリック(またはタップ)するとボタンの色やデザインが変わって、いいね済みであることが視覚的にわかります。

Reactコンポーネントを埋め込む理由

いいね機能自体はRails(+Vanilla JS)やjQueryで簡単に実装することができますが、本記事ではあえてReactコンポーネントとして実装します。 この方式には次のようなメリットがあると考えています。

フロントエンドの保守性を上げる

Vanilla JS、jQueryはHTML/CSS/JavaScriptを別々のファイルに記述するケースが多く、規模が大きくなると保守が難しくなります。

Reactコンポーネントであれば、1つのファイルにHTML/CSS/JavaScriptを記載することになるので、保守や流用がしやすくなります。

SPA化に向けた布石を打てる

既にあるシステムをSPA化したいといったシチュエーションで、一気に全ページをSPA化するのはリスクが高いです。

まずはRailsのViewを部分的にReactコンポーネント化し、Reactの資産が溜まった段階でSPA化を進めると、スムーズに移行することができます。

Reactの資産を活用できる

世の中には既にReactで実装されたパッケージやコンポーネントが多数公開しています。

Railsを使うことでスピーディに開発を進めつつ、Reactを使ったリッチなUIを実現しやすくなります。

使用するバージョン

  • ruby 2.7.1
  • Rails 6.0.3.4
  • node v14.14.0

バックエンド(Rails)

念のため、1から作る前提で記載します。既に実装済の手順は飛ばして構いません。

rails new、deviseでログイン機能を追加

まずは rails new し、必要なgemを追加します

$ rails new react_on_rails_likes
$ cd react_on_rails_likes

# gemを追加
$ bundle add devise
$ bundle add react_on_rails

# deviseをインストール
$ rails g devise:install
$ rails g devise user
$ rails db:migrate

app/controller/application_controller.rb に以下を追記します。

class ApplicationController < ActionController::Base
  before_action :authenticate_user! # 追記
end

Railsサーバを起動して http://localhost:3000/users/sign_in にアクセスすると、ログイン画面が表示されます。

# Railsサーバを起動
$ rails s

投稿機能をscaffold

Railsのscaffoldを活用して、一気に作ります。

# モデルを生成
$ rails g scaffold posts message:text
$ rails db:migrate

# Railsサーバを起動
$ rails s

http://localhost:3000/posts にアクセスすると投稿されたメッセージの一覧が表示されます。

いいね/いいねのキャンセル用のエンドポイントを追加

「いいね」は favorites というテーブルに永続化する方針でモデル・コントローラを構築します。

# モデルを生成
$ rails g model favorites post:references user:references
$ rails db:migrate

# コントローラを生成
$ rails g controller favorites create destroy --skip-template-engine --skip-assets --skip-helper

app/models/user.rbhas_many を追加しておきます。

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  
  has_many :favorites # 追加
end

同様に、 app/models/post.rb にも has_many を追加します。 また、特定のユーザがいいね済みかどうか判定するメソッドも追加しておきましょう。

class Post < ApplicationRecord
  has_many :favorites, dependent: :destroy # 追加

  def favorite_by(user)
    favorites.find{|f| f.user_id == user.id}
  end
end

同じユーザが何度もいいねできないよう、 app/models/favorite.rb にて一意性チェックを追加します。

class Favorite < ApplicationRecord
  belongs_to :post
  belongs_to :user

  validates :post_id, uniqueness: { scope: :user_id }
end

config/routes.rb を次のように修正します。

Rails.application.routes.draw do
  resources :posts
  resources :favorites, only: [:create, :destroy] # 追加
  devise_for :users
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end

app/controllers/favorites_controller.rb を記述し、いいね/いいねのキャンセル処理を実装します。

class FavoritesController < ApplicationController
  def create
    favorite = current_user.favorites.create!(favorite_params)
    render json: favorite
  end

  def destroy
    favorite = Favorite.find(params[:id])
    favorite.destroy
    render json: favorite
  end

  private

  def favorite_params
    params.require(:favorite).permit(:post_id)
  end
end

フロントエンド(React)

react_on_railsをインストール

$ rails generate react_on_rails:install
$ bundle install
$ bundle exec rails webpacker:install:react

このままだと、react_on_railsが自動生成したHelloWorld.jsxが 「Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:1. You might have mismatching versions of React and the renderer (such as React DOM)」 というエラーを出すので、以下のように修正しておきます。

import PropTypes from 'prop-types';
import React, { useState } from 'react';

const HelloWorld = (props) => {
  const [name, setName] = useState(props.name);

  return (
    <div>
      <h3>Hello, {name}!</h3>
      <hr />
      <form>
        <label htmlFor="name">
          Say hello to:
          <input id="name" type="text" value={name} onChange={(e) => setName(e.target.value)} />
        </label>
      </form>
    </div>
  );
};

HelloWorld.propTypes = {
  name: PropTypes.string.isRequired, // this is passed from the Rails view
};

export default props => <HelloWorld {...props} />; // ここを修正
# Railsサーバを起動
$ rails s

# 別のterminalでwebpack-dev-serverを起動
$ bin/webpack-dev-server

http://localhost:3000/hello_world にアクセスすると HelloWorld.jsx を含む次のような画面が表示されます。

ReactコンポーネントにHTML/CSSを実装

いいねボタンのコンポーネントを作成して、Railsに埋め込めるようにします。 Bootstrap IconsをReactコンポーネントから利用できるよう、 react-bootstrap-icons をインストールします。

$ yarn add react-bootstrap-icons

app/javascript/packs/bundle.js を作成します。

import ReactOnRails from 'react-on-rails';

import Favorite from '../bundles/Favorite';

// This is how react_on_rails can see the HelloWorld in the browser.
ReactOnRails.register({
  Favorite,
});

app/javascript/bundles/Favorite.jsx を作成します。

import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { HeartFill } from 'react-bootstrap-icons';

const Favorite = (props) => {
  const { favoriteId } = props;
  const [id, setId] = useState(favoriteId);

  const clicked = () => {
    console.log('clicked');
  }

  return (
    <>
      <HeartFill color={id ? 'red' : 'gray'} size={32} onClick={clicked}/>
    </>
  );
};

Favorite.propTypes = {};

export default props => <Favorite {...props} />;

app/views/layouts/application.html.erb にてReactコンポーネントを含むJSを読み込みます。

<!DOCTYPE html>
<html>
  <head>
    <title>ReactOnRailsLikes</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'bundle' %> <%# ここを追加 %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

app/views/posts/index.html.erb にてReactコンポーネントのレンダリングを追加します。

<p id="notice"><%= notice %></p>

<h1>Posts</h1>

<table>
  <thead>
    <tr>
      <th>Message</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @posts.each do |post| %>
      <tr>
        <td><%= post.message %></td>
        <td><%= link_to 'Show', post %></td>
        <td><%= link_to 'Edit', edit_post_path(post) %></td>
        <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>

        <%# 次の行を追加 %>
        <td><%= react_component("Favorite", props: { postId: post.id, favoriteId: post.favorite_by(current_user)&.id }, prerender: false) %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New Post', new_post_path %>

N+1を防ぐために app/controllers/posts_controller.rb も修正しておきましょう。

class PostsController < ApplicationController
  before_action :set_post, only: [:show, :edit, :update, :destroy]

  # GET /posts
  # GET /posts.json
  def index
    @posts = Post.eager_load(:favorites) # この行を修正
  end

  # 以下、省略

非同期処理(Ajax)を実装

ここまで来れば、あとはいいねボタンをクリックしたときの処理を実装するのみです。

import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { HeartFill } from 'react-bootstrap-icons';
import axios from 'axios';

axios.defaults.headers['X-CSRF-TOKEN'] = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

const Favorite = (props) => {
  const { postId, favoriteId } = props;
  const [id, setId] = useState(favoriteId);

  const clicked = async () => {
    if (id == null) {
      const { data: { id = null } } = await axios.post('/favorites', { post_id: postId })
      id && setId(id);
    } else {
      await axios.delete(`/favorites/${id}`)
      setId(null);
    }
  }

  return (
    <>
      <HeartFill color={id ? 'red' : 'gray'} size={32} onClick={clicked}/>
    </>
  );
};

Favorite.propTypes = {};

export default props => <Favorite {...props} />;

バックエンドにRailsを使う都合上、 X-CSRF-TOKEN をセットする必要がある点に注意してください。

あとはReactコンポーネント内の postId の有無に応じてリクエスト先を切り替えればOKです。

まとめ

このようにReactコンポーネントとして、Reactの資産を増やしていくことでRailsとReactの両方のメリットを活かした開発が可能です。 みなさんも、ぜひお試しください。

Yuhei Okazaki

Yuhei Okazaki

Twitter X

2018年の年明けに組込み畑からやってきた、2児の父 兼 Webエンジニアです。 mockmockの開発・運用を担当しており、組込みエンジニア時代の経験を活かしてデバイスをプログラミングしたり、簡易的なIoTシステムを作ったりしています。主な開発言語はRuby、時々Go。