ReactHooks + Firebase(Authentication, Firestore)でTodoアプリ作る

アドカレには間に合わなかったけど、ReactHooksとFirebaseでTodoアプリ作ったので手順まとめます。

# 今回作ったもの
ログインしてTodo登録してってやつを作りました。
![todo.gif](https://qiita-image-store.s3.amazonaws.com/0/16629/a0ed8f3e-bac9-d646-c1f8-bbd47a26d6e5.gif)

コードはこちら
https://github.com/k-tada/react-firebase-todo

# 使った技術
- React(v16.7.0-alpha.2)
    - Hooks使えるバージョン
- Firebase
    - Authentication
    - Firestore
    - Hosting

# Firebaseの準備
## Firebaseプロジェクトを作る
https://console.firebase.google.com/u/0/?hl=ja
にアクセスし、プロジェクトを追加します。

<img width="961" alt="スクリーンショット 2018-12-29 2.33.21.png" src="https://qiita-image-store.s3.amazonaws.com/0/16629/388f9677-ddcf-a0ed-4bf7-966e411f7ddd.png">


## Authenticationを有効化
左メニューの**Authentication**を選択し、**ログイン情報**で**メール/パスワード**を有効にします。
<img width="882" alt="スクリーンショット 2018-12-29 2.36.00.png" src="https://qiita-image-store.s3.amazonaws.com/0/16629/b2173042-e01c-fcb4-a34a-0b419a96640b.png">

## Firestoreの有効化、設定
左メニューの**Database**を選択し、**データベースの作成**を選択します。

<img width="722" alt="スクリーンショット 2018-12-29 2.52.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/16629/1b7617e6-74e1-14a6-62bf-325854c37fb1.png">

1. データベースを作成  
一旦テストモードで作成します。ルールは後で作ります。  
<img width="685" alt="スクリーンショット 2018-12-29 2.52.49.png" src="https://qiita-image-store.s3.amazonaws.com/0/16629/7d0e7087-710a-f5f8-f1c7-bdee513e8e49.png">

2. コレクションを追加  
コレクションを追加するためにテストデータを１件登録しておきます。
<img width="701" alt="スクリーンショット 2018-12-29 2.53.49.png" src="https://qiita-image-store.s3.amazonaws.com/0/16629/cc6af87f-aed3-3c9d-d037-4cfcbb29e836.png">
<img width="697" alt="スクリーンショット 2018-12-29 2.54.14.png" src="https://qiita-image-store.s3.amazonaws.com/0/16629/943edc1c-49a3-59d8-420c-ff45670aad91.png">

# プロジェクトの作成
parcelでちゃちゃっと作るのが楽なので今回もparcel使います。

## 各種インストール、設定

```shell:いつもの
$ mkdir react-firebase-todo && cd $_
$ npm init -y
$ npm i -S react@next react-dom@next styled-components @material-ui/core firebase
$ npm i -D parcel-bundler
```

```diff:package.json
   "scripts": {
+    "start": "parcel src/index.html -d public"
   },
```

Lint+Prettierの設定もサクッと用意

```bash
$ npm i -D eslint prettier prettier-eslint prettier-eslint-cli babel-eslint
```

## Firebaseの設定
Firebaseの初期化処理と必要なAPIの設定を行います。
認証情報はFirebase Consoleの**Authentication**の右上にある**ウェブ設定**からコピペしてください。

```javascript:src/js/utils/firebase.js
import firebase from 'firebase'

firebase.initializeApp({
  // 認証情報をここに
})

const auth = firebase.auth()
const db = firebase.firestore()
db.settings({ timestampsInSnapshots: true })

export { auth, db }
```

## 認証用のContextを作る
FirebaseのAuthenticationを使って認証用のContextを作成します。
作成するContextの構成はこんな感じ。

```javascript:src/js/contexts/auth.js
const AuthContext = createContext()
const AuthProvider = ({ children }) => {
  :
  :
}

export { AuthContext, AuthProvider }
```

この`AuthProvider`内に処理を追加していきます。

まずは`useState`で認証済みユーザのローカルステートを作成。

```javascript:src/js/contexts/auth.js
  const [currentUser, setCurrentUser] = useState(null)
```

初回アクセス時に`firebase.auth().onAuthStateChanged()`を使って認証済みかどうかのチェックを行います。

```javascript:src/js/contexts/auth.js
  useEffect(() => {
    auth.onAuthStateChanged(user => setCurrentUser(user))
  }, [])
```

後はサインアップ、ログイン、ログアウト用のメソッドを用意します。
基本すべて同じ作り方なのでサインアップメソッドだけ載せます。
`firebase.auth().createUserWithEmailAndPassword()`でサインアップ後、`firebase.auth().onAuthStateChanged()`で認証情報を更新しています。

```javascript:src/js/contexts/auth.js
  const signup = async (email, password) => {
    await auth.createUserWithEmailAndPassword(email, password)
    auth.onAuthStateChanged(user => setCurrentUser(user))
  }
```

後はProviderでラップしたchildrenを返してやればOKです。

```javascript:src/js/contexts/auth.js
  return (
    <AuthContext.Provider value={{ currentUser, signup, signin, signout }}>
      {children}
    </AuthContext.Provider>
  )
```

全体像はこんな感じ。なんかもうちょっと上手く書く方法あるかもしれないけど今は(ﾟεﾟ)ｷﾆｼﾅｲ!!

```javascript:src/js/contexts/auth.js
import React, { createContext, useState, useEffect } from 'react'
import { auth } from '../utils/firebase'

const AuthContext = createContext()

const AuthProvider = ({ children }) => {
  const [currentUser, setCurrentUser] = useState(null)

  const signup = useCallback(async (email, password) => {
    await auth.createUserWithEmailAndPassword(email, password)
    // auth.onAuthStateChanged(user => setCurrentUser(user))
  }, [])

  const signin = useCallback(async (email, password) => {
    await auth.signInWithEmailAndPassword(email, password)
    // auth.onAuthStateChanged(user => setCurrentUser(user))
  }, [])

  const signout = useCallback(async () => {
    await auth.signOut()
    // auth.onAuthStateChanged(user => setCurrentUser(user))
  }, [])

  useEffect(() => {
    auth.onAuthStateChanged(user => setCurrentUser(user))
  }, [])

  return (
    <AuthContext.Provider value={{ currentUser, signup, signin, signout }}>
      {children}
    </AuthContext.Provider>
  )
}

export { AuthContext, AuthProvider }
```

## Todo用のContextを作る
FirebaseのFirestoreと接続してTodoの取得、追加、更新、削除を行うContextを作ります。

まずは取得。
`firebase.firestore().collection(name).onSnapshot()`を使ってリアルタイムに更新されるようにします。
ついでに、`firebase.firestore().collection(name)`までの部分を変数に括りだすためにまとめて`useMemo`で処理します。
`AuthContext`で管理している`currentUser`の`uid`を使って、自分のTodoのみを取得するようにしています。更新、削除用にドキュメントIDを添えてデータを保存しています。

```javascript:src/js/contexts/todos.js
  const [todos, setTodos] = useState([])
  const { currentUser } = useContext(AuthContext)
  const collection = useMemo(() => {
    const col = db.collection('todos')

    // 更新イベント監視
    col.where('uid', '==', currentUser.uid).onSnapshot(query => {
      const data = []
      query.forEach(d => data.push({ ...d.data(), docId: d.id }))
      setTodos(data)
    })

    return col
  }, [])
```

Todoの追加、更新、削除処理は↑の`useMemo`で取得した`collection`を対象に行います。
追加処理はこんな感じ。

```javascript:src/js/contexts/todos.js
  const add = useCallback(async text => {
    await collection.add({
      uid: currentUser.uid,
      text,
      isComplete: false,
      createdAt: new Date(),
    })
  }, [])
```

後はAuthContextと同様にProviderでラップしてやればOK。

全体像はこんな感じ。

```javascript:src/js/contexts/todos.js
import React, {
  createContext,
  useContext,
  useState,
  useMemo,
  useCallback,
} from 'react'
import { AuthContext } from './auth'
import { db } from '../utils/firebase'

const TodosContext = createContext()

const TodosProvider = ({ children }) => {
  const [todos, setTodos] = useState([])
  const { currentUser } = useContext(AuthContext)

  const collection = useMemo(() => {
    const col = db.collection('todos')

    // 更新イベント監視
    col.where('uid', '==', currentUser.uid).onSnapshot(query => {
      const data = []
      query.forEach(d => data.push({ ...d.data(), docId: d.id }))
      setTodos(data)
    })

    return col
  }, [])

  const add = useCallback(async text => {
    await collection.add({
      uid: currentUser.uid,
      text,
      isComplete: false,
      createdAt: new Date(),
    })
  }, [])

  const update = useCallback(async ({ docId, text, isComplete }) => {
    const updateTo = {
      ...todos.find(t => t.docId === docId),
      text,
      isComplete,
    }
    if (isComplete) {
      updateTo.completedAt = new Date()
    }
    await collection.doc(docId).set(updateTo)
  }, [todos])

  const remove = useCallback(async ({ docId }) => {
    await collection.doc(docId).delete()
  }, [todos])

  return (
    <TodosContext.Provider value={{ todos, add, update, remove }}>
      {children}
    </TodosContext.Provider>
  )
}

export { TodosContext, TodosProvider }
```

## Contextを使って残りのReactを作る
### 未認証の場合はログイン画面を表示させる
今回はreact-routerとかを使うのがめんどくさかったので自前で超簡易的なルーティング処理を作ってます。

まずはルートコンポーネントでAuthProviderでラップする。

```javascript:src/js/components/App.js
export default () => (
  <AuthProvider>
    <Main>
      <Router
        renderTodos={() => <Todos />}
        renderLogin={() => <Login />}
      />
    </Main>
  </AuthProvider>
)
```

Routerコンポーネントで認証状態でrenderするコンポーネントを変えてやる。

```javascript:src/js/components/Router.js
export default ({ renderLogin, renderTodos }) => {
  const { currentUser } = useContext(AuthContext)

  return (
    <Fragment>
      {currentUser ? renderTodos() : renderLogin()}
    </Fragment>
  )
}
```

後はLoginコンポーネントとTodosコンポーネントを作ればOK。
この辺のコードは割愛。

Contextの値、メソッドを使いたいときは下記のように`useContext`でContextから抽出して使えばOK。

```javascript
const { signin } = useContext(AuthContext)
```

# Firestoreルールの設定
FirebaseコンソールからFirestoreを作成した際はテストルールを選んでいたので基本的にはすべての読み取り書き取りが許可される状態になっています。
次項のホスティングを行う前にこの辺をちゃんと修正してやりましょう。

今回はコレクションが`todos`のみで、各todoドキュメントのuidでどのユーザのTodoなのかを管理するような設計にしているので、以下のようなルールを設定します。

- 読み取りは一旦全部許可
- 新規作成は認証済みユーザのみ許可
- 更新、削除は対象ドキュメントの作成ユーザのみ許可

上記ルールをFirestoreに設定する場合の定義は下記の様になります。
多分読めばわかるので説明はばっさりカット。

```
service cloud.firestore {
  match /databases/{database}/documents {
    match /todos/{todo} {
      function isSignedIn() {
        return request.auth != null;
      }
      function isAuthor() {
        return request.auth.uid == resource.data.uid;
      }

      allow read;
      allow create: if isSignedIn()
      allow update, delete: if isAuthor();
    }
  }
}
```

この設定をFirebaseコンソールの**Database**→**ルール**に記載してやればOK。

# ホスティング
コードが完成したらFirebaseのHostingを使ってホスティングしましょう。
今回はCLIを使ってホスティングします。

## Firebase CLIのインストール
```bash
npm i -g firebase-tools
ndenv rehash # ndenv使ってる人だけ
```

## ホスティィィング
```bash
firebase login
# ログイン

firebase init
# Authentication, Firestore, Hostingを選択して後は適宜選択してinit

firebase deploy
```

# あとがき
最近何かと話題のReactHooksを使ってFirebaseとの連携をしてみました。
認証処理、データ処理なんかをContextにまとめることで、Reduxなどのmiddlewareを使わなくても見通しのいいコードを書くことが出来たと思います。
Contextのおかげでpropsのバケツリレーが無いのもいいですね。
個人的にはContext + Hooksでビジネスロジック周りの処理をまとめる書き方結構気に入ったので簡単なサービスを開発するときはこの方針でやってやろうと思ってます。

あとFirebaseが噂に聞いていた通り非常に便利だったので、もう少し使い込んでみたいと思いました（小並感