React+FirebaseでサーバーレスなTodoAppを作った

[React入門](https://www.amazon.co.jp/React入門-React・Reduxの導入からサーバサイドレンダリングによるUXの向上まで-NEXT-ONE-穴井-宏幸/dp/4798153354/ref=sr_1_1?ie=UTF8&qid=1521264208&sr=8-1&keywords=react入門)をざっくり読んだので何か作ってみようと思った。
チュートリアルといえばTodoApp作成。
ただのTodoAppでは味気ないのでDB保存機能も付けようと。
じゃあ、最近イケてるっぽいサーバーレスっぽい構成にしてみるか、ということでReact+FirebaseなTodoAppを作った。

https://todo.dyoshikawa.net

かきすてTodo。
匿名ログインしてログアウトするまでの間Todoが保持される。

# 対象読者

- React初心者
- サーバレス、mBaaS初心者

# 使ったアレコレ

- React
- Firebase Hosting
- Firebase Authentication
- Firebase Firestore
- Material-UI

# 筆者環境

- OSX High Sierra
- Node v9.8.0

# 準備

- Node, yarn(npm)導入
- Firebaseアカウント登録・プロジェクト作成
- firebase-tools導入
- ブラウザFirebaseコンソールでAuthentication, Hosting, Firestoreを使用開始状態にする

これらは割愛。
Reactプロジェクト作成はcreate-react-appでドン。

~~~bash
npx create-react-app my-todoapp
~~~

my-todoappをFirebaseプロジェクト化する。

~~~bash
cd my-todoapp
firebase init
~~~

対話式で設定する。
基本yでOKだが、プロジェクト選択は任意に今回使うものに合わせてEnter。publicはbuildと入力。
あと使用サービスを聞かれた時は、HostingとAuthenticationとFirestoreに**スペースキー**でチェックを入れてEnter。
僕は最初スペースで選択というのが分からず未選択のまま次項目へ行ってしまった。

Firebaseとmaterial-uiのモジュールを追加。

~~~bash
yarn add firebase material-ui@next
~~~

# 実装

JSを書いていく。

## firebase.js

React初心者なのでディレクトリ構成の定番がよくわからないけれども、とりあえずsrc下にpluginsを作る。
pluginsに自前Firebaseモジュール的なものとしてfirebase.jsを書く。

~~~js:plugins/firebase.js
import firebase from 'firebase';

const config = {
  apiKey: "",
  authDomain: "",
  databaseURL: "",
  projectId: "",
  storageBucket: "",
  messagingSenderId: ""
};
const firebaseApp = firebase.initializeApp(config);
export const firestore = firebaseApp.firestore();
~~~

configオブジェクトの中身はブラウザのFirebaseコンソールから確認する。
左メニューAuthenticationを選択→右上のウェブ設定をクリックすると見れるので、コピペする。
ついでに、Authenticationのログイン方法→匿名を有効にする。

## App.js

App.jsを書きかえる。

## Authentication関数

firebaseモジュールをimportする。

~~~js
import firebase from 'firebase';
~~~

### ログイン

~~~js
firebase.auth().signInAnonymously()
~~~

### ログイン状態を監視

~~~js
firebase.auth().onAuthStateChanged(user => {
  if (user) {
    // ログイン中
  } else {
    //　ログアウト中
  }
});
~~~

### 現在のユーザを取得

~~~js
firebase.auth().currentUser
~~~

`firebase.auth().currentUser.delete()`関数でログアウトできる。


## Firestore関数

公式ドキュメントが親切。

https://firebase.google.com/docs/firestore/manage-data/add-data
https://firebase.google.com/docs/firestore/manage-data/delete-data
https://firebase.google.com/docs/firestore/query-data/get-data

RailsとかでORM書いた経験あるとかなりとっつきやすいんじゃないだろうか。

どうやって使うかというと、さっきのfirebase.jsを読み込む。

~~~ｊｓ
import { firestore } from './plugins/firebase';
~~~

これでfirestore関数が使えるように・・・ならない。
consoleを見るとエラーが出ている。
ググったが理由がよくわからない。
結果的に以下を挿入すると動くようになった。

~~~js
import 'firebase/firestore';
~~~

たぶん僕がimportを完全に理解できてないだけだと思う。

## Material-ui

公式を参照。

https://material-ui-next.com/

雰囲気はわかるが、全部英語なので細かいところで結構詰まる。
手探りで実装した感が否めない(まあ、他の部分もそうか)。

以下、app.jsの完成形。

~~~js:src/app.js
import React, { Component, Fragment } from 'react';
import firebase from 'firebase';
import 'firebase/firestore';
import { firestore } from './plugins/firebase';
import Reboot from 'material-ui/Reboot';
import List from 'material-ui/List';
import ListItem from 'material-ui/List/ListItem';
import ListItemText from 'material-ui/List/ListItemText';
import Button from 'material-ui/Button';
import TextField from 'material-ui/TextField';
import AppBar from 'material-ui/AppBar';
import Toolbar from 'material-ui/Toolbar';
import Typography from 'material-ui/Typography';
import Checkbox from 'material-ui/Checkbox';

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      userId: '',
      isLogin: false,
      inputValue: '',
      tasks: []
    };
    this.getTasksData = this.getTasksData.bind(this);
    this.login = this.login.bind(this);
    this.logout = this.logout.bind(this);
    this.getText = this.getText.bind(this);
    this.addTask = this.addTask.bind(this);
    this.removeTask = this.removeTask.bind(this);
  }

  componentDidMount() {
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        console.log('is login');
        this.setState({
          userId: user.uid,
          isLogin: true
        });
        this.getTasksData();
      } else {
        console.log('is not login');
      }
    });
  };

  getTasksData() {
    firestore.collection('tasks')
      .where('user_id', '==', this.state.userId)
      .orderBy('created_at')
      .get()
      .then(snapShot => {
        let tasks = [];
        snapShot.forEach(doc => {
          tasks.push({
            id: doc.id,
            text: doc.data().text
          });
        });
        this.setState({
          tasks: tasks
        });
      });
  }

  login() {
    firebase.auth().signInAnonymously().then(e => {
      console.log(e);
      this.setState({
        isLogin: true,
        userId: firebase.auth().currentUser.uid
      });
    }).catch(error => {
      console.log(error.code);
      console.log(error.message);
    });
  };

  logout() {
    if (firebase.auth().currentUser == null) {
      return
    }

    firebase.auth().currentUser.delete()
      .then(() => {
        this.setState({
          isLogin: false,
          userId: '',
          inputValue: '',
          tasks: []
        });
      });
  };

  getText(e) {
    this.setState({
      inputValue: e.target.value
    });
  };

  addTask() {
    const inputValue = this.state.inputValue;
    if (inputValue === '') {
      return
    }
    firestore.collection('tasks').add({
      text: inputValue,
      created_at: new Date(),
      user_id: this.state.userId
    }).then(() => {
      this.getTasksData();
    });
    this.setState({
      inputValue: ''
    });
  };

  removeTask(e) {
    console.log(e.target.value);
    firestore.collection('tasks')
      .doc(e.target.value)
      .delete()
      .then(() => {
        this.getTasksData()
      });
  };

  render() {
    const setLoginOutButton = () => {
      if (this.state.isLogin) {
        return (
          <Button variant='raised' color='secondary' onClick={this.logout}>
            ログアウト
          </Button>
        );
      } else {
        return (
          <Button variant='raised' color='primary' onClick={this.login}>
            匿名ログイン
          </Button>
        );
      }
    };

    const setPlaceholder = () => {
      if (this.state.isLogin) {
        return 'Todo'
      } else {
        return '匿名ログインして下さい';
      }
    };

    return (
      <Fragment>
        <Reboot/>
        <AppBar position='static'>
          <Toolbar>
            <Typography type='title' color='inherit' style={{ fontSize: '20px' }}>
              かきすてTodo
            </Typography>
          </Toolbar>
        </AppBar>

        <div style={{ padding: '16px' }}>
          <div>
            {setLoginOutButton()}
          </div>

          <div>
            <List>
              {
                this.state.tasks.map(task => {
                  return (
                    <ListItem key={task.id}>
                      <Checkbox color='secondary' onClick={this.removeTask} value={task.id}/>
                      <ListItemText primary={task.text}/>
                    </ListItem>
                  );
                })
              }
            </List>
          </div>

          <div>
            <TextField placeholder={setPlaceholder()} onChange={this.getText} value={this.state.inputValue}
                       disabled={!this.state.isLogin}/>
            <Button variant='raised' color='primary' onClick={this.addTask} disabled={!this.state.isLogin}>
              追加
            </Button>
          </div>
        </div>
      </Fragment>
    );
  };
}

export default App;
~~~

サービス名がTodoなのに変数名をtasks, taskにしたのは失敗だったな。
コンポーネント化は後でやる(やらない)。

## firestore.indexes.json

インデックスがない状態でwhere句を使おうとするとなぜか怒られるので設定しておく。

https://firebase.google.com/docs/firestore/query-data/indexing

公式ドキュメントにはJSON形式の書き方が載ってないっぽいのでノリで書いた。

~~~json:firestore.indexes.json
{
  "indexes": [
    {
      "collectionId": "tasks",
      "fields": [
        {
          "fieldPath": "user_id",
          "mode": "ASCENDING"
        },
        {
          "fieldPath": "created_at",
          "mode": "ASCENDING"
        }
      ]
    }
  ]
}
~~~

## firestore.rules

firestoreのセキュリティルールを設定する。
公式ドキュメントを参照。

https://firebase.google.com/docs/firestore/security/rules-conditions

~~~:firestore.rules
service cloud.firestore {
  match /databases/{database}/documents {
    match /tasks/{task} {
      allow read: if request.auth.uid == resource.data.user_id;
      allow write: if request.auth.uid != null;
    }
  }
}
~~~

とりあえず、

- ログインユーザだけがTodoを書ける
- 自分のTodoが他人に見られないようにする

と設定したつもり。
間違ってたら指摘お願いします。

# デプロイ

~~~
yarn build && firebase deploy
~~~

# 感想

なんかほとんどFirebaseの記事になってしまった。

VPS借りてNginxやらRoRやらPHPやらMySQLやら環境構築して〜というバックエンドの部分から開放されるのは確かに楽。
人的リソースの少ない趣味開発やスピード命のスタートアップではガンガン使っていくべきなんじゃないだろうか(僕が言うまでもなく使ってるんだろうけど)。

今回はReact単体で作ったけど、RouterとかReduxにも慣れていきたい。
サーバーレスは同じFirebaseのCloud Functionsか、AWS Lambda, DynamoDB方面にも手を出してみようか。