日本一わかりやすいReact-Redux講座 実践編 #3 学習備忘録

## はじめに
この記事は、Youtubeチャンネル『トラハックのエンジニア学習ゼミ【とらゼミ】』の『[日本一わかりやすいReact-Redux講座 実践編](https://www.youtube.com/watch?v=8fNgXnVTULU)』の学習備忘録です。

前回記事は[こちら]()。

## 要約
- 認証状態の永続化のため、``認証のリッスン機能``を作成する
- 認証のリッスン機能を持たせた``<Auth>``コンポーネントで、リッスン対象をラッピングする
- パスワードリセット機能は、firebase.authの機能で簡単に実装できる。

## #3 Firebase Authで認証をリッスンしよう

### 認証状態の永続化

前回講座までで「サインアップ」「サインイン」の機能を実装しましたが、今の状態ではまだ不完全です。

例えば、サインイン直後ではユーザーの情報が state に正しく保存されているので、Home画面にはユーザーIDやユーザー名が表示されます。

しかし、この状態で画面をリロードしたり、ブラウザを立ち上げ直して再度アクセスすると、 state は初期状態に戻ってしまいます。

これは、**React の state は、画面をリロードしたり、ブラウザを立ち上げ直したりすると初期化されてしまう性質**を持っているからです。

これでは、 state の初期化が走るたびにサインインし直す必要があり、Webアプリとしては非常に使い勝手が悪くなってしまいます。

そのため、「ユーザー認証がすでに行われている状態であれば、適宜データベース側と通信してユーザー情報を state に保存する」という処理を入れ必要があります。

これを実現するのが``認証のリッスン``という処理なのですが、その前に一度、``React``+``firebase``における認証の仕組みを簡単に整理しておきます。

### React + firebase による認証の仕組み

そもそも ``firebase.Auth`` では、ユーザーの``認証情報``をどのように判断するのでしょうか？言い換えると、判断元となるユーザーの``認証情報``は、どこに保存されているのでしょうか？

こちらのteratail記事（『[firebase.auth().onAuthStateChangedはどうやってログイン中であることを判定しているんでしょうか？](https://teratail.com/questions/193911)』）によると、``firebase.Auth`` は「サインアップまたはサインイン処理を行った時、ユーザーの認証情報を``indexedDB``という、ブラウザ内にあるデータベースに保存する」とのことです。

これは、「ブラウザ内にある情報を保存する場所」という意味では``localStorage``に近い役割のものです。これらの違いについて詳しくは調べていませんが、ここで大切なのは「ブラウザ側に``認証情報``が保存される」ということです。

そのため、``React``+``firebase``アプリにおいて、

1. React の state に保存されている``ユーザー情報``
2. firebaseにより ブラウザの``indexedDB``へ保存された``認証情報``

がそれぞれ存在しているということになります。

ユーザー情報を画面描画に使用するためには、当然 React の state に値を入れる必要がありますが、これは画面リロードのたびに消えてしまいます。

対して、``indexedDB``に保存された情報は、画面リロードやブラウザ立ち上げなどでは削除されません。ただし、ここで保存している``認証情報``は、ユーザー名やメールアドレスなどのユーザー情報そのものではなく、「データベース(Cloud Firestore)から特定のユーザー情報を引き出すための鍵」のようなもので、そのまま使用することができないものです。

そのため、これらを組み合わせた以下のような流れで「認証状態の永続化」を実現します。

1. 画面のリロードなどが行われた時、まず``indexedDB``を見て認証の有無を確認。
2. 未認証なら initialState を state に入れて画面描画。認証済みであれば、``indexedDB``に保存された認証上を用いてCloud Firestoreと通信し、特定のユーザーの情報を取ってくる。
3. 取ってきたユーザー情報を state に入れて画面描画する

このような流れを実装することで、サインインを行った特定のユーザーのサインイン状態が永続化し、何度もサインインをし直すという状態を防ぐことができます。

この**今、ユーザーが認証しているかどうかを確認し、state に適切な値を入れる一連の作業のことを、認証のリッスン**と呼びます。

### 認証のリッスン

Reactで認証のリッスンを行うためには、「``認証のリッスン処理を行うコンポーネント``を作成し、これで対象のコンポーネントをラッピングする」というやり方をとります。

``認証のリッスン処理を行うためのコンポーネント``は、一般的には``<Auth>``という名前で定義されます。

``<Auth>``コンポーネントを``Router.jsx``において呼び出し、「認証済みの状態でのみ描画したいコンポーネント」をラッピングすることで、上記処理を実現します。

```react:src/Router.jsx
import React from 'react';
import {Route, Switch} from "react-router";
import {Home,SignIn,SignUp} from "./templates";
import Auth from "./Auth"

const Router = () => {
  return (
    <Switch>
      <Route exact path={"/signup"} component={SignUp} />
      <Route exact path={"/signin"} component={SignIn} />
      
      //追記
      <Auth>
        <Route exact path={"(/)?"} component={Home} />
      </Auth>
      //追記ここまで
    </Switch>
  );
};

export default Router

```

SignUp, SignIn画面は、認証のリッスンを行いません。なぜなら、ユーザー情報を state に保存する必要がない（＝未認証のユーザーでもアクセス可能）なページにしたいからです。

一方、``path={"(/)?"}``へアクセスすると、``<Auth>``コンポーネントにより認証のリッスンが行われます。

「認証がされていればコンポーネントを描画」「未認証であれば別の処理（例えばSignInへリダイレクト）」という条件分岐を行うことで、認証状態でのみアクセス可能な画面を実装できます。



### 認証リッスン関数を作成
``<Auth>``コンポーネントを作る前に、認証リッスンを実行する``listenAuthState``関数を作成します。

firebase authでは、現在認証中のユーザーを取得するためのメソッドとして、``onAuthStateChanged()``があります。これを使用することで認証のリッスンを簡単に実装することができます。




users stateに関わる関数のため、``signUp``関数や ``signIn``関数と同様に、users/operations.js に定義していきます。

```javascript:src/reducks/users/operations
import { signInAction } from "./actions";
import {push} from "connected-react-router";
import {auth, db, FirebaseTimestamp} from "../../firebase/index"

export const listenAuthState = () => {
  return async (dispatch) => {
    return auth.onAuthStateChanged(user => {
      if (user) {
        const uid = user.uid

        db.collection("users").doc(uid).get()
          .then(snapshot => {
            const data = snapshot.data()

            dispatch(signInAction({
              isSignedIn: true,
              role: data.role,
              uid: uid,
              username: data.username
            }))
          })
      } else {
        dispatch(push("/signin"))
      }
    })
  }
}
.
.
.
```

``auth.onAuthStateChanged()``というメソッドは、ユーザーの認証状態に応じて、返り値を変える（条件分岐して処理を変えることができる）メソッドです。


``onAuthStateChanged()``メソッドを実行すると、アプリはブラウザ内の``indexedDB``を見に行き、そこにユーザーに関する情報があれば取ってきて、``user``という返り値を返します。

``if(user)``、すなわち``user``が存在しているのであれば、それはユーザーが認証済みということになるので、``user.uid``の情報をもとに``Cloud firestore``から該当するユーザーの情報を取得し、stateに保存します。（また、``Cloud firestore``との通信を行うため、この関数は``return async (dispatch) ...``という redux-thunk を利用した書き方を行い、非同期制御をする必要があります）

逆に、``user``が存在しないのであれば、ユーザーが未認証の状態ということになるので、``dispatch(push("/signin"))``でサインイン画面にリダイレクトをさせます。

### Authコンポーネントの作成

先ほどの``listenAuthState()``関数を利用して``<Auth>``コンポーネントを作成し、``<Auth>``タグで囲まれたコンポーネントには、認証リッスンが走るようにします

ひとつ事前準備として、``<Auth>``コンポーネントでは、ユーザーのサインイン情報を示すstateである``user.isSignedIn``を使用するため、それを取得するためのselectorを定義します。

```javascript:src/reducks/users/selectors.js
import { createSelector } from "reselect";

const usersSelector = (state) => state.users;

export const getSignedIn = createSelector(
  [usersSelector],
  state => state.isSignedIn
)

.
.
.
```

``getSignedIn ()``と記述することで、Store内の``user.isSignedIn``の値を外部からでも参照できるようになりました。これも用いて、``<Auth>``コンポーネントを作成します。

``<Auth>``コンポーネントを作成する場所は決められていませんが、今回は使用先の``Router.jsx``と同階層である``src``ディレクトリ直下に作ります。

```react:src/Auth.jsx
import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { getSignedIn } from "./reducks/users/selectors";
import { listenAuthState } from "./reducks/users/operations";

const Auth = ({children}) => {
  const dispatch = useDispatch();
  const selector = useSelector((state) => state);

  const isSignedIn = getSignedIn(selector);

  useEffect(() => {
    if (!isSignedIn) {
        dispatch(listenAuthState())
    }
  }, []);

  if (!isSignedIn) {
    return <></>
  } else {
    return children
  }
};

export default Auth;
```

``children``とは、「子要素全体」を意味する特別なpropsです。例えば、アクセスしているアドレスが``(/)``であれば、``<Route exact path={"(/)?"} component={Home} />``がここに丸ごと入っているイメージ。

``<Auth>``コンポーネントがマウントされるとき、まず最初に``useEffect``内の処理が実行されます(第２引数に``[]``を与えているため、``componentDidMount()``と同じ役割)。先ほど定義した``listenAuthState``が実行され、ユーザー認証のリッスンが行われます。

``if (!isSignedIn)`` 、すなわちユーザーが未認証のときは、``<Auth>``コンポーネントは、空のHTMLタグ``<></>``を返すようにしています。なぜなら、ユーザーが未認証のときは``listenAuthState``が実行された時点で``dispatch(push("/signin"))``でサインイン画面にリダイレクトされることとなっているため、画面描画用のHTMLタグを用意する必要がないからです。

ユーザーが認証されていれば、``<Auth>``コンポーネントは``children``、すなわち子要素全体(アクセスしているアドレスが``(/)``であれば、``<Route exact path={"(/)?"} component={Home} />``)を描画する。

これでHomeコンポーネントが、ユーザー認証状態でしか描画できない(``(/)``にアクセスできない)状態になったはずです。

## SignOut の実装
認証のリッスンについて動作確認をする前に、SignOut 機能も実装してしまいます。SignOutを行うためには、


1. firebase.auth でサインアウト処理を実行する(＝ブラウザの indexedDB からユーザー情報を削除する)
2. Redux Store内の users state を、initialState に戻す

の二つの処理を行う必要があります。

実装のイメージとして、何らかのテンプレート内に配置する「SIGN OUT」ボタンのonClickイベントとして、上記処理が走る関数(=``signOut()``)を埋め込むことになるはずです。

その関数は user state に関連するものなので、``reducks/users/operations.js``に、まず``signOut()``を定義します。

``signOut()``の中で、「firebase.auth でサインアウト処理を実行」→「Redux Store内の users state を、initialState に戻すアクションを実行」という流れにすることで、上記二つの処理を同時に行うことができるはずです。

以上を踏まえると、編集ファイルは以下の通り。

```
1. src/reducks/users/operations.js // signOut()関数を定義
2. src/reducks/users/actions.js // 定義済みの signOutAction を一部編集
3. src/reducks/users/reducers.js // case Action.SIGN_OUT を追加
4. src/templates/Home.jsx // signOut()関数を発火するボタンを追加
```



```react:src/reducks/users/operations.js
import { signInAction,signOutAction } from "./actions";
.
.
.
export const signOut = () => {
  return async (dispatch) => {
    auth.signOut()
    .then(() => {
      dispatch(signOutAction());
      dispatch(push('/signin'));
    })
  }
}
```

``auth.signOut()``は、firebase.authとしてのサインアウト処理を行うメソッドです。

その後、usersのアクションで定義する``signOutAction()``を発火することで、アプリ側のサインアウトも行われます。

```react:src/reducks/users/actions.js
.
.
.
export const SIGN_OUT = "SIGN_OUT";
export const signOutAction = () => {
  return {
    type: "SIGN_OUT",
    payload: {
      isSignedIn: false,
      role: "", // 追記
      uid: "",
      username: ""
    }
  }
}
```
payloadの中身は、initialState での定義と全く同じです。これにより、Redux Store内の users state が、initialState に戻るようにしています。

```javascript:src/reducks/users/reducers.js
import * as Actions from './actions'
import initialState from '../store/initialState'

export const UsersReducer = (state = initialState.users, action) => {
  switch (action.type) {
    case Actions.SIGN_IN:
      return {
        ...state,
        ...action.payload
      };
    // 追記
    case Actions.SIGN_OUT:
      return {
        ...action.payload
      };
    // 追記ここまで
    default:
      return state
  }
}
```

users の initialState と全く同じ payload を、スプレッド構文で展開しています。これが Store に渡ることで、state の中身が書き換わります。

```react:src/templates/Home.jsx
import React from 'react';
import {getUserId, getUserName} from '../reducks/users/selectors';
import {useSelector, useDispatch} from 'react-redux'
import {signOut} from "../reducks/users/operations"　// 追記

const Home = () => {
  const dispatch = useDispatch()　// 追記
  const selector = useSelector(state => state);
  const uid = getUserId(selector);
  const username = getUserName(selector);

  return (
    <div>
      <h2>Home</h2>
      <p>ユーザーID:{uid}</p>
      <p>ユーザー名:{username}</p>
      <button onClick={() => dispatch(signOut())}>SIGN OUT</button> // 追記
    </div>
  );
};

export default Home
```

「SIGN OUT」ボタンをクリックすることで、operationで定義した``signOut()``が発火するようにしています。

ここまでくれば、実装は完了です！動作確認をしてみます。

新しいユーザーを一人新規作成するところから始めます。

#### ``localhost:3000/signup``

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/0486d2d1-56dc-cf41-24a8-28a73b8c3149.png)

「アカウントを登録する」をクリックすることで、ユーザーが登録され、画面がルートへリダイレクトされます。

#### ``localhost:3000/``

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/be16856e-fc84-4865-5cdb-38ae8d6e7b68.png)

先ほど配置した「SIGN OUT」ボタンがありますね。これをクリックすることで、サインアウト処理が行われ、かつ画面がサインイン画面にリダイレクトされます。

#### ``localhost:3000/signin``

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/f00c778a-10e9-15d7-f806-54c69986ff72.png)

この状態で``localhost:3000/``にアクセスしてみても、強制的に``localhost:3000/signin``画面にリダイレクトされるはずです。

サインアウト処理が正常に行われており、かつ``<Auth>``コンポーネントのラッピングによる認証のリッスンが機能していることが確認できます。

これで、認証のリッスンおよびサインアウト処理の実装が完了しました！

### パスワードリセットの作成

認証周りの最後の機能として、パスワードリセット機能を作ります。

firebase.authが提供する``sendPasswordResetEmail()``を利用することで、簡単にパスワードリセットを実装できます。

```:新規作成および編集ファイル
1. src/templates/Reset.jsx // パスワードリセット用のテンプレート
2. src/templates/index.js // Reset.jsxをエントリーポイントに追加
3. src/Router.jsx // Reset.jsxのルーティングを定義
4. src/reducks/users/operations.js // パスワードリセット用の関数を定義
5. src/templates/SignIn.jsx // SignUp, Resetへ遷移するリンクタグを追加
6. src/templates/SignUp.jsx // SignIp, Resetへ遷移するリンクタグを追加
```

5,6に関してはパスワードリセット機能の実装とは直接関係はないが、画面遷移がURL手打ちのままでも味気ないので、モックとしてそれっぽいリンクタグを配置しておく。

```react:src/templates/Reset.jsx
import React, {useState,useCallback} from "react";
import {PrimaryButton,TextInput} from "../components/UIkit"
import {resetPassword} from "../reducks/users/operations"
import {useDispatch} from "react-redux"

const Reset = () => {
  const dispatch = useDispatch()

  const [email,setEmail] = useState()

    const inputEmail = useCallback((event) => {
      setEmail(event.target.value)
    },[setEmail])

  return(
    <div className="c-section-container">
      <h2 className="u-text__headline u-text-center">パスワードリセット</h2>
      <div className="module-spacer--medium" />

      <TextInput
        fullWidth={true} label={"メールアドレス"} multiline={false}
        required={true} rows={1} value={email} type={"email"} onChange={inputEmail}
      />

      <div className="module-spacer--medium" />

      <div className="center">
        <PrimaryButton
          label={"パスワードリセット"}
          onClick={() => dispatch(resetPassword(email))}
        />
      </div>
    </div>
  )
}

export default Reset
```

- 基本構造は``SignIn.jsx``と同じ。パスワードリセットではメールアドレスしか使用しない点に注意。
- 「パスワードリセット」ボタンをクリックすることで、この後実装する``resetPassword()``が実行され、パスワードリセットの処理が走ることになる。

```react:src/templates/index.js
export {default as Home} from './Home'
export {default as Reset} from './Reset' //追記
export {default as SignIn} from './SignIn'
export {default as SignUp} from './SignUp'

```

- テンプレートを追加したら、忘れずにエントリーポイントに追記をする。


```react:src/Router.jsx
import React from 'react';
import {Route, Switch} from "react-router";
import {Home,Reset,SignIn,SignUp} from "./templates"; //追記
import Auth from "./Auth"

const Router = () => {
  return (
    <Switch>
      <Route exact path={"/signup"} component={SignUp} />
      <Route exact path={"/signin"} component={SignIn} />
      <Route exact path={"/signin/reset"} component={Reset} /> //追記

      <Auth>
        <Route exact path={"(/)?"} component={Home} />
      </Auth>
    </Switch>
  );
};

export default Router
```

- Resetテンプレートに対して、``(/signin/reset)``というURLをルーティング。
- Resetテンプレートはユーザー未認証状態でも表示させたいので、``<Auth>``ではラッピングしない。

```javascript:src/reducks/users/operations.js
.
.
.
export const resetPassword = (email) => {
  return async (dispatch) => {
    if (email === "") {
      alert("必須項目が未入力です")
      return false
    } else {
      auth.sendPasswordResetEmail(email)
      .then(() => {
        alert('入力されたアドレスにパスワードリセット用のメールを送りました。')
        dispatch(push('/signin'))
      }).catch(() => {
        alert('パスワードリセットに失敗しました。通信環境を確認してください。')
      })
    }
  }
}
.
.
.
```

- 「emailが空欄ではいけない」というバリデーションをかけている。
- ``auth.sendPasswordResetEmail(email)``メソッドが実行されると、firebase側より、パスワードリセット用のメールが届く仕組みになっている。


```react:src/templates/SignIn.jsx
.
.
.
<PrimaryButton
  label={"サインイン"}
  onClick={() => dispatch(signIn(email,password))}
/>
<div className="module-spacer--medium" />
<p onClick={() => dispatch(push('/signup'))}>アカウントをお持ちでない方はこちら</p> //追記
<p onClick={() => dispatch(push('/signin/reset'))}>パスワードをお忘れの方はこちら</p> //追記
.
.
.
```

- クリックすると``('/signup')``, ``('/signin/reset')``それぞれに遷移するリンクを、「サインイン」ボタン直下に配置
- ``<div className="module-spacer--medium" />``はタグ同士の空欄を表現するタグ(``styles.css``で定義)


```react:src/templates/SignUp.jsx
.
.
.
<PrimaryButton
  label={"アカウントを登録する"}
  onClick={() => dispatch(signUp(username,email,password,confirmPassword))}
/>
<div className="module-spacer--medium" />
<p onClick={() => dispatch(push('/signin'))}>アカウントをお持ちの方はこちら</p> //追記
.
.
.
```

- クリックすると``('/signup')``に遷移するリンクを、「アカウントを登録する」ボタン直下に配置

これで、実装は一通り完了です。動作確認します。

実際にメールを受け取ることができるアドレスで、ユーザー登録をしてみます。

#### ``localhost:3000/signup``
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/c448683c-9873-9e00-f446-31448572918a.png)

新規登録が成功すれば、ルートにリダイレクトします。

#### ``localhost:3000/``
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/3ae71554-c890-590e-1923-ffbf75f172a2.png)

いったんサインアウトします。

#### ``localhost:3000/signin``
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/bb645d9d-3de7-226c-2aea-46bdf8e39b85.png)

「パスワードをお忘れの方はこちら」より、パスワードリセット画面へ飛びます。

#### ``localhost:3000/signin/reset``
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/aee62ec3-4cea-1871-483c-bc35b4cd3e13.png)

メールアドレスを入力して、「パスワードリセット」ボタンを押すと、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/88c1ad53-e52d-9c77-9698-c0061c499b0b.png)

先ほど定義した通りのalert文が出ていますね。「OK」を押すと、サインイン画面にリダイレクトされます。

実際にメールボックスを確認すると、firebase より、パスワードリセット用のメールが届いているはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/6e7ba36c-67e2-c363-1fc0-f789655b2027.png)

URLをクリックすると、firebaseが用意しているパスワードリセット用の画面に飛びます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/40bae127-8578-7ab7-b1e2-e7218da06e9f.png)

新しいパスワードをSAVEします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/8138d6a2-9173-0946-b83d-5de7a6457354.png)

再度、Reactアプリのサインイン画面から、新しいパスワードを用いてサインインしてみます。

#### ``localhost:3000/signin``
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/90ede7aa-e1f8-4bc7-341b-6f7cd6b20629.png)

#### ``localhost:3000/``
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/540216/e3a519d5-90aa-fbcf-f49e-c0b9c6463ca6.png)

新しいパスワードでのサインインができました！

### おわり

要点をまとめると、

- 認証状態の永続化のため、``認証のリッスン機能``を作成する
- 認証のリッスン機能を持たせた``<Auth>``コンポーネントで、リッスン対象をラッピングする
- パスワードリセット機能は、firebase.authの機能で簡単に実装できる。

今回はここまで！講座一回あたりの内容がボリューミーになってきたので、基本1講座1記事の単位で投稿していくことになりそうです。