Railsチュートリアルを完全SPA化してみた

# Railsチュートリアルを完全SPA化してみた
今回は、自分なりに**Rails tutorial**を`Rails API`と`next`を用いて**SPA化**してみたので、それを振り返って見ようと思います。

基本的に**バックエンド**側の内容は、**Rails tutorial**の`render`を`jbuilder`や`json:`に変更してる程度なので、詳しくは**Rails tutorial**をご購入ください。
ですので今回の記事では、**フロントエンド**の内容をメインに振り返りたいと思います。

## サイト概要
**Rails Tutorial**(rails ver 6.0)用のアプリケーションを`Rails API`と`next --typescript`を用いて完全SPA化しました。

[プロダクトページはこちら](https://rails-tutorial.vercel.app/)
自分が見本としたチュートリアルである、[Rails tutorialのページはこちら](https://railstutorial.jp/chapters/beginning?version=6.0)(有料です）

**Next.js**による**フロントエンド**側のAppレポジトリーは[こちら](https://github.com/takaya787/tut_frontend)　
**Rails API**を用いた**バックエンド**側のレポジトリーは[こちら](https://github.com/takaya787/tut_backend)

## 対象読者
* **Rails Tutorial**を終了した後に、**SPA**開発に挑戦してみたい方
* `Rails API`と`React`を用いた**SPA**開発に興味がある方
* バックエンドAPIも含んだ、**SPA**を開発してみたい方

オリジナルのポートフォリオを`Rails API`と`React`を用いた**SPA**で作成しようと考えている方がいれば、予行演習として**Rails Tutorial**を**SPA化**してみることは非常にオススメです！

## 作成理由
**Rails Tutorial**は初めて**Rails**を触れる人にとっては非常に優れた教材だと思います。現在**Rails**を勉強中で、まだ購入していない人はぜひ購入してみてください。
ただ、現在は**SPA(Single Page Application)**が非常に流行っているので、**Rails tutorial**のバックエンドを用いて**SPA**作成の勉強のために作成しました。
各章ごとにブランチを残しているので、もし興味がある人はぜひチェックしてみてください。
[フロントエンドレポジトリーはこちら](https://github.com/takaya787/tut_frontend)　
[バックエンドレポジトリーはこちら](https://github.com/takaya787/tut_backend)　

## 使用技術  一覧
* Ruby 2.7.2
* Rails 6.1.1
* Next 10.0.9
* Typescript
* React
* react-bootstrap
* Docker, Docker-compose (開発環境)
* Postgresql(DB)
* Google Cloud Storage(画像保存用)
* Send Grid(Email配信用)
* Vercel (フロントエンドレポジトリーのデプロイ先)
* Heroku (バックエンドレポジトリーのデプロイ先)

## 機能一覧
◆　ユーザー機能
* 新規登録、ログイン、ログアウト、emailによる**ユーザー有効化**、**パスワードリセット**
* マイページ、登録情報を変更

◆　マイクロポスト機能
* マイクロポスト作成、編集、消去
* マイクロポストに画像を追加(`Google Cloud Storageに保存`)

◆　ユーザー機能 
* 他のユーザーをフォロー
* フォローしたユーザーのフォローを解除
* フォローしたユーザー、フォローされているユーザーを確認

◆　フィード機能
* フォローしたユーザーのマイクロポストを表示

### いいね機能を新たに実装しました(2021年10月)
◆　いいね機能
* 投稿をお気に入りできる
* 各投稿毎にいいね数といいねしたユーザーを確認できる
* いいねした投稿を一覧で確認できる（実装予定)

## ERD図
![スクリーンショット 2021-11-04 17 20 47](https://user-images.githubusercontent.com/67105080/140280592-9f6664a2-e8b2-48f9-bef6-af8f2af88694.png)

## 開発環境について

開発環境は開発者の好みで`Docker`を用いて開発しています。
`backend`,`frontend`それぞれにレポジトリーを作成して、管理しました。

```
├── backend
│   ├── Dockerfile
│   ├── Gemfile
│   ├── Gemfile.lock
│   ├── Guardfile
│   ├── README.md
│   ├── Rakefile
│   ├── app
│   ├── bin
│   ├── config
│   ├── config.ru
│   ├── db
│   ├── entrypoint.sh
│   ├── lib
│   ├── log
│   ├── public
│   ├── storage
│   ├── test
│   ├── tmp
│   └── vendor
├── docker-compose.yml
└── frontend
    ├── Dockerfile
    ├── README.md
    └── app
```
`Docker-compose`の内容はこちらになります。
自分が開発環境を作成するための手順もコメントとして残してあります。

```yml:docker-compose.yml
version: "3"
services:

  #typescript用フロントエンド
  #step1 docker-compose build
  #step2 docker-compose run --rm frontend sh -c "npx create-next-app"
  #step3 workingdirは"create-next-app"を実行してからコメントを外す
  #step4 tsconfig.jsonを作成する
  #step5 docker-compose run --rm frontend sh -c "yarn add --dev typescript @types/react @types/node"
  frontend:
    container_name: ts_frontend
    build:
      context: frontend/
      dockerfile: Dockerfile
    volumes:
      - ./frontend/app:/usr/src/app
    tty: true
    # working_dir: "/usr/src/app"
    command: 'npm run dev'
    ports:
      - "8080:3000"

  #step1 docker-compose run --rm backend bash -c "rails new . --force --database=postgresql --api"
  #step2 config databaseを 設定する
  #自分がハマったミス　rails new --appしてから、docker-compose build
  #イメージを先に作ってはいけない
  #step3 docker-compose build
  #step4 docker-compose up
  #step5 docker-compose exec {service name} bach or docker-compose run --rm {service name} rails db:create
  #step6 rails db:create

  backend:
    # container_name: backend
    build:
      context: backend/
      dockerfile: Dockerfile
    # 起動前にbundle installをしないとエラーがでるようになった
    command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle install && bundle exec rails s -p 3000 -b '0.0.0.0'"
    # command: /bin/sh -c "rm -f tmp/pids/server.pid  && bundle exec rails s -p 3000 -b '0.0.0.0'"
    tty: true
    stdin_open: true
    depends_on:
      - db
    ports:
      - "3000:3000"
    volumes:
      - ./backend:/api

  db:
    # container_name: psql_server
    image: postgres
    volumes:
      - psgl_data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: password
    ports:
      - 3308:3306


volumes:
  psgl_data:
```
## SPAにする際に難しかった点
* ログイン認証、ログイン情報の保持
* ページネーションの作成
* 画像の表示
* Flashメッセージの表示


###  ログイン認証、ログイン情報の保持について
>ログイン認証について

今回のログインは`JWT(Json Web Token)`を用いて実装しました。
まず、**client**側から**server**側にログインに必要な情報を送信し、ユーザーが認識されたら**user_id**を**JWT化**させ、**token**を`local storage`に保存しています。
そして、**client側**からログイン情報を必要とする処理を行う場合は、**header**に**token**を追加して、送信することで**server**側にログインユーザーを伝えています。

>ログイン情報の保持について

**client**側でのログイン情報は広範囲のcomponentで共通して利用する必要がある情報なので、`swr`を用いて**custom Hook**化し、全てのcomponentで簡単に情報を共有できるように設定しました。
コード例は以下の通りです。

```typescript:useUserSWR
import useSWR from 'swr';
// SWR用のfetcher
async function UserFetcher(): Promise<UserDataType | null> {
  const response = await fetch(AutoLoginUrl, {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${Auth.getToken()}`,
      'Content-Type': 'application/json'
    }
  });
  return response.json()
}
export function useUserSWR(): useUserType {
  const { data: user_data, error: user_error } = useSWR(AutoLoginUrl, UserFetcher)

  const has_user_key = (): boolean => {
    return user_data.hasOwnProperty('user')
  }
  return { user_data, user_error, has_user_key }
}
```

### 投稿のリストのデザインについて
今回の**投稿**の表示には、`react-bootstrap`の`Card`コンポーネントを用いています。
`Card`コンポーネント内で、レイアウトを調整し、また投稿に画像が付属されている場合、画像を一緒に表示させています。
投稿日時の表記は、`react-timeago`を利用して実装しました。
`created_at`を**props**として渡すだけなので、非常に簡単に実装できると思います。

```typescript
import TimeAgo from 'react-timeago'
<footer className="blockquote-footer py-2 my-0 mx-auto" style={{ width: "80%" }}>
          Posted <TimeAgo date={new Date(post.created_at)} />
          <br className="sp-only" />
          <span className="float-center mx-3"><MicropostEdit id={post.id} user_id={post.user_id} isEdit={isEdit} setIsEdit={setIsEdit} /></span>
          <span className="float-right"><MicropostDelete id={post.id} user_id={post.user_id} /></span>
</footer>
```

### ページネーションの作成について
**ページネーション**は、`rails tutorial`では`gem`を導入するだけで作成できていましたが、`react`では当然そうは行きません。　(笑)
自分は、**ページネーション**の`state`を作成するための`Custom Hook`と、**ページネーション**の番号部分を表示する`Component`に分けて作成しました。
>Custom Hook `usePagination`について

ページネーションを利用する**component**には以下の`state`が必要です。

```typescript
type PageStateType = {
  currentPage: number,
  totalPage: number,
  maxPerPage: number
}
```
ですので予め、これらを簡単に設定できるように`Custom Hook`を作成しました。
`maxperpage`のみ`props`として設定できるようにしてあります。

```typescript:usePagination.ts
import { useState, Dispatch, SetStateAction } from 'react'

export type PageStateType = {
  currentPage: number,
  totalPage: number,
  maxPerPage: number
}

export type usePaginationType = {
  pageState: PageStateType,
  setPageState: Dispatch<SetStateAction<PageStateType>>
}

export function usePagination({ maxPerPage }: { maxPerPage: number }): usePaginationType {
  //Pagination用のstate管理
  const [pageState, setPageState] = useState<PageStateType>({
    currentPage: 1,
    totalPage: 0,
    maxPerPage: maxPerPage
  });

  return { pageState, setPageState }
}
```
>`Component`について

ページネーションの番号を表示するコンポーネントでは、`usepagination`の`state関数`を、そのまま**props**として受け取れるようにでように設定しました。

```typescript:Pagination_Bar.tsx
import { PageStateType } from '../hooks/usePagination'
//Bootstrap
import Pagination from 'react-bootstrap/Pagination'

export const Pagination_Bar: React.FC<PaginationPropType> = ({ pageState, setPageState }) => {
 return (
  <Pagination className="justify-content-center">
      <Pagination.First key={"First"} onClick={() => setPageState(Object.assign({ ...pageState }, { currentPage: 1 }))} />
      <Pagination.Prev key={"Prev"} onClick={() => ClickPrev(currentPage)} />
      {Pagination_Numbers({ totalPage: totalPage, currentPage: currentPage })}
      <Pagination.Next key={"Next"} onClick={() => ClickNext(currentPage, totalPage)} />
      <Pagination.Last key={"Last"} onClick={() => setPageState(Object.assign({ ...pageState }, { currentPage: totalPage }))} />
  </Pagination>
 )
}

```


### 画像の表示について
今回の画像は、ほとんどが外部のサイトからurlを通して読み込んで表示しています。（**User icon**は`gravator`、**ポストの画像は`GCS`)
ですので、普通の`next/image`では読み込むことができないので、予め外部の画像を読み込む用のコンポーネントを`External_Image`として設定しました。

```typescript:External_Image.tsx
import Image from 'next/image'
import React from 'react'
type Gravatar_Props = {
  src: string,
  height: number,
  width: number,
  alt?: string,
  className?: string
}

//外部Image用のComponent
export const External_Image: React.FC<Gravatar_Props> = ({ src, alt, height, width, className }) => {
  const myLoader = ({ src, width }) => {
    return `${src}?w=${width}`
  }
  return (
    <Image loader={myLoader} className={className}
      src={src} alt={alt} width={width} height={height}
    />
  )
}
```

### Flashメッセージの表示方法
`rails`では、デフォルトで**Flashメッセージ**を表示することができますが、**next**で表示するには当然自分で作成する必要があります。
今回は、**Flashメッセージ**を、`userReducer`、`Alert/react-bootstrap`を用いて作成して、`useContext`によって共有しました。

```typescript:FlashReducer.ts
//types
import { FlashStateType, FlashActionType } from '../types/FlashType'
export function FlashReducer(state: FlashStateType, action: FlashActionType): FlashStateType {
  switch (action.type) {
    case "DANGER":
      return { ...state, show: true, variant: "danger", message: action.message };
    case "INFO":
      return { ...state, show: true, variant: "info", message: action.message };
    case "PRIMARY":
      return { ...state, show: true, variant: "primary", message: action.message };
    case "SUCCESS":
      return { ...state, show: true, variant: "success", message: action.message };
    // これはflash messageの表示をやめるコマンド
    case "HIDDEN":
      return { ...state, show: false };
    default:
      return state;
  }
}
```
```typescript:_app.tsx
//FlashMessageをContext化
  import React, { useReducer, createContext } from 'react';
//reducers
  import { FlashReducer } from '../reducers/FlashReducer'
  const initialflashstate: FlashStateType = { show: false, variant: "primary", message: "message" }
  const [FlashState, FlashDispatch] = useReducer(FlashReducer, initialflashstate);
  const FlashValue: { FlashState: FlashStateType, FlashDispatch: React.Dispatch<FlashActionType> } = { FlashState, FlashDispatch }
  return (
    <FlashMessageContext.Provider value={FlashValue}>
      <Component {...pageProps} />
    </FlashMessageContext.Provider>
  )
```
**Flashメッセージ**は画面がある程度下にスクロールされると、`position:fixed`が適用されるように設定しました。

```ts:Layout.tsx
{
          scrollY >= 130 ? (
            <div className="fixed-top m-3">
              <Alert show={FlashState.show} variant={FlashState.variant} onClose={() => FlashDispatch({ type: "HIDDEN" })} transition={true} bsPrefix="alert" dismissible>
                <div className={`${styles.flash_message}`}>
                  {FlashState.message}
                </div>
              </Alert>
            </div>) : (
            <div className="shadow">
              <Alert show={FlashState.show} variant={FlashState.variant} onClose={() => FlashDispatch({ type: "HIDDEN" })} transition={true} bsPrefix="alert" dismissible>
                <div className={`${styles.flash_message}`}>
                  {FlashState.message}
                </div>
              </Alert>
            </div>
          )
}
```

## まとめ
もともと**rails tutorial**自体は一度やったことがあって、今回の**SPA化**に挑戦してみましたが、３月末から初めて約2ヶ月かかりました。
パフォーマンス性の向上は、**SPA化**することで格段に上がりますが、完成するまでの期間の差を考えると、本当に**SPA**で作るべきものなのかどうかを考慮する必要性を強く実感できますね。