😸

Firebase + React で Todoist のクローンを作る

2021/08/15に公開

Firebase と React の勉強を兼ねて、愛用しているタスク管理アプリ Todoist のサブセットとなる機能を持つクローンを作ってみた。途中で色々試行錯誤したのだが、「最初から最短の手順で作るならこういう手順になる」というのを備忘と Firebase の紹介も兼ねてまとめてみようと思う。

アプリケーション: https://altech-todoist.web.app/
ソースコード: https://github.com/Altech/todoist-clone

0. はじめに

技術スタック

  • 言語: TypeScript
  • UIライブラリ: React 17
  • バックエンド:Firebase (Authentication + Cloud Firestore + Hosting)
  • Firebase SDK: v9
  • モジュールバンドラー: Snowpack
  • CSS:styled-components

実行時の依存パッケージとしては以下のようなシンプルなものになる。

// package.json
  "dependencies": {
    "firebase": "9.0.0-beta.2",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "styled-components": "^5.3.0"
  },

解説

Firebase の SDK は2021年8月現在、v9 がベータ版として公開されており、API が大幅に変わって洗練されている。遠からずこのバージョンが主流になると期待できるため、ここでは v9 を使って開発する。

Snowpack はビルド後にも ES Module を使った、シンプルなビルドツールだ。開発環境では、TypeScript で定義したモジュールがそのまま ES Module となりブラウザで読み込まれるため、高速かつ透過性も高いため、好んで使っている。

styled-components は CSS の記述に使う。別に生の CSS を使っても良いのだが、CSS in JS の方がパッと作れるのでとりあえずこれを使う。この記事の本筋には関わってこない。

作るもの

Todoist のサブセットとなる機能を持つクローン・アプリケーション。

https://altech-todoist.web.app/

具体的には、以下のような機能を持つ。

  • Sign Up
  • Sign In
  • Sign Out
  • List tasks
  • Add a task to inbox
  • Edit a task
  • Delete a task
  • Mark task as done
  • Schedule a task
  • List projects
  • Add a project
  • Delete a project
  • Add a task to a project
  • List tasks of a project
  • Filter all tasks by schedule
Edit a task Add a project

1. セットアップ

バックエンド側

Firebase プロジェクトを作る

まずは https://console.firebase.google.com/u/0/ から Firebase プロジェクトを一つ作る。

Cloud Firestore でデータベースを作る

Firebase の機能の一つである、Cloud Firestore(以下、Firestore)を使うために、データベースを一つ作ることになる。コンソール画面。

img

コンソールをいじってみるといろいろとわかる。

  • コレクションは名前によって明示的に認識されている
  • インデックスは複合インデックスがここから貼れる(単一フィールドでの検索や親から子への探索は自動でインデックスがある)
  • データに対するルール(セキュリティルール)はコンソールから更新する
  • セキュリティルールを適用した結果(成功/拒否)のモニタリングが提供されている
  • データに対するモニタリングも提供されており、read, write, delete とリアルタイム更新(スナップショットリスナー)に分かれる

img

img

img

Firebase アプリを作る

Firebase の各機能を使う上で、Firebase 上にアプリを作る必要がある。

img

これを進めていくと API key などのクレデンシャル情報が手に入るので、以降はこれを使う事になる。

// ./firebase.ts
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';

export const firebaseApp = initializeApp({
  apiKey: '...',
  authDomain: 'altech-todoist.firebaseapp.com',
  projectId: 'altech-todoist',
  storageBucket: 'altech-todoist.appspot.com',
  messagingSenderId: '1020253811345',
  appId: '...',
  measurementId: '...',
});
export const firebaseAuth = getAuth(firebaseApp);
export const firebaseFirestore = getFirestore(firebaseApp);

このあたりはスタートガイド https://firebase.google.com/docs/firestore/quickstart#web-v9 が参考になる。

解説

Firebase で開発する上では、「プロジェクト」がルート要素となる。このプロジェクトに、「アプリ」を一つ一つ追加していくことができる。アプリは iOS / Android / Web などプラットフォームごとに別々になっている。同じく「プロジェクト」に Cloud Firestore のデータベースが一つ付いてくる。

上記の初期化のコードを見てもわかるように、Reactアプリは projectIdappId を通して実行時に結びつくようになっている。

こういう設計なので、例えば(やるかどうかはさておくとして)管理画面用の Web アプリをユーザー向けの Web アプリとは別でさらに追加する、というようなこともできるだろう。

フロントエンド側

snowpack で React アプリを作る:

$ yarn create-snowpack-app todoist-clone --template @snowpack/app-template-react-typescript

依存パッケージを追加する:

$ yarn add firebase@9.0.0-beta.2
$ yarn add styled-components

Firestore との結合を確認する

addDoccollection を使って、ドキュメントを追加してみる:

import { addDoc, collection } from 'firebase/firestore';

import { firebaseFirestore } from './firebase';

function App({}: AppProps) {
  // Create the count state.
  const [count, setCount] = useState(0);
  // Create the counter (+1 every second).
  useEffect(() => {
    const timer = setTimeout(() => setCount(count + 1), 1000);
    return () => clearTimeout(timer);
  }, [count, setCount]);

  // Add this section
  useEffect(() => {
    addDoc(collection(firebaseFirestore, 'people'), { name: 'Adam' })
      .then((docRef) => {
        console.log('Document written with ID: ', docRef.id);
      })
      .catch((e) => {
        console.error('Error adding document: ', e);
      })
      .finally(() => console.log('finally'));
  }, [count]);
  return <div>...</div>;
}

こうすると、パーミッションが足りていないというエラーがブラウザのコンソールに出る。エラーではあるが、これはセキュリティルールを満たしていない操作を行ったためであり、Firestore 自体には接続できているということでもある。

以下のように編集したセキュリティルールを Firestore のコンソールで適用する:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /people/{peopleID} {
      allow read, write
    }
  }
}

これにより、people コレクションへの書き込みができる。コンソールでどんどんデータの行が増えていく状態が確認できる。セキュリティルールについては別途、Firestore のデータ設計と共に取り上げるので、ここでは最低限の確認に留める。

解説

Firestore の詳細については Cloud Firestore  |  Firebase に譲るとして、ここでは SDK のインターフェイス を眺めながら、概念と API の対応付けを要約する。

ドメイン用語

まずドメイン用語としての ドキュメントコレクション がある。ドキュメントは Firestore に格納されるオブジェクトの一つ一つを表す。任意のドキュメントはある一つのコレクションに属す。ドキュメントとコレクションはそれぞれパスを持つ。

パスは、/users/526865/tasks/12 のようにコレクションIDとドキュメントIDが交互に並ぶ。/users はユーザーのコレクションを表すし、/users/526865 はその中のある一つのユーザー・ドキュメントを表す。/users/526865/tasks はそのユーザーが持つタスクのコレクションを表す。といった具合だ。

Firestore のデータは、セキュリティルールで保護されるのだが、セキュリティルールはパスを元に主としてコレクションに設定する。上では、people というコレクションに対して読み込み・書き込みを許可した。

クラス

コレクションやドキュメントといった Firestore 上のモノを参照するための、 DocumentReference, CollectionReference というクラスが用意されている。それぞれ、collection 関数の戻り値、doc 関数の戻り値として登場する。DocumentReference は、任意のキーとバリューを持ちうるが、DocumentReference<Task> のように型パラメータを指定することで特殊化することが可能だ。

CollectionReference の基底クラスとして Query がある。Query に対しては、データを読み込んだり変更を監視したりでき、かつ絞り込みを行なったり、並び替えを行なったりできる。陽には query 関数の戻り値として登場するが、暗には collection 関数の戻り値として登場しているので、Query が取得操作の中心的なクラスとなる。

関数

これは Firebase SDK v9 からの設計のようだが、オブジェクトのメソッドではなく、関数が操作の中心となる。

書き込み系だと、addDocsetDocdeleteDoc などの関数が提供されている。CRUD の R 以外である。

読み込み系だと、onSnapshot が提供されている。CRUD の R であるが、これは Firestore の特長でもある、リアクティブ・プログラミングを行うための少し高級な仕組みだ。まず スナップショット と言う用語だが、これはデータは刻々と変化するものであり、その中のある時点の状態を写しとったもの(snapshot)、という風に理解すると良いだろう(これはリアクティブ・プログラミングの枠組みでしばしば登場するモデル化の仕方だ)。これに対応するように、DocumentSnapshot、QuerySnapshot のようなモノのある瞬間の状態を写しとったクラスが存在する。次のような整理が可能だ。

ドキュメント コレクション
データへの参照 DocumentReference CollectionReference
データのスナップショット DocumentSnapshot CollectionSnapshot

onSnapshot は「対象となるモノ」(コレクション,クエリ,ドキュメント)を第一引数に受けとり、それに対する値の変化の結果を第二引数に渡すオブジェクトの next というプロパティ名に対応するコールバックで受け取る(ソースコード上は "observer" というローカル変数で呼ばれているようだ)。

以上が覚えておくべき基本的な関数だろう。

セキュリティルールもコレクションに対して適用される。コレクションに属することを通じて、ドキュメントも階層に属しており、従ってセキュリティルールも適用される。

Authentication との結合を確認する

タスクはユーザーごとに管理したく、そのユーザーは認証を行った状態にしたい。認証をするのは Firestore とは異なる、Authentication という機能を利用する。Authentication には一般的に使う認証機構は大体用意されているが、今回は email ログインだけにしよう。

・・・とはいえ、これは特にいうことがないので詳述しない。createUserWithEmailAndPassword(firebaseAuth, email, password)signInWithEmailAndPassword(firebaseAuth, email, password) といった関数が提供されているので、フォームにつなぎこむと登録・ログインの機能がつけられる。

一点だけ注意することとしては、Firebase アプリを渡してすでに冒頭で作った firebaseAuth(Authクラス)には、currentUser: User | null というプロパティが存在するが、React アプリケーションを読み込んだ時点ではこれは null である。通信をしているのだろう、しばらく立つと User が入るが、React アプリケーションはこれを関知できないし、null が読み込み中なのかそれとも読み込んだ結果未ログインなのかが判別できない。

API を眺めていると onAuthStateChanged という関数が提供されているので、これを使って現在のユーザーと、ロード中かどうかの両方を判別し、変化を監視するのが正しい。次のような hooks に切り出した(異常系を考慮するともう少しコードが増える)。

// ./hooks/useAuthState.ts
import { useEffect, useState } from 'react';
import { onAuthStateChanged, User } from 'firebase/auth';

import { firebaseAuth } from '../firebase';

export const useAuthState = (): [User | null, boolean] => {
  const [loading, setLoading] = useState<boolean>(true);
  const [user, setUser] = useState<User | null>(null);

useEffect(() => {
  const unsubscribe = onAuthStateChanged(firebaseAuth, (user) => {
    setLoading(false);
    setUser(user);
  });
  return unsubscribe;
}, [firebaseAuth]);

  // TODO: Return error as third value.
  return [user, loading];
};

解説

React のように、値の変化に応じてリアクティブに動作することが前提のアプリケーションでは、このように値が変化したときに介入できるインターフェイスがライブラリ側に必要となる。これは通常の手続き的なプログラミングとはちょっと違った慣れの必要な部分であり、またライブラリを開発するときには特に留意が必要な点だと思う。

2. タスクを表示する

セットアップが整ったので、機能を一つ一つ追加してこう。単に表示する方が簡単なので、まずはタスクを表示できるようにしたい。

Firestore 自体はスキーマレスなデータストアだが、実際にアプリケーションを作る上では、コレクションごとにどういうフィールドの集合を持つかは決まるだろう。

したがって、TypeScript と一緒に使うとどういう開発スタイルになるかというと、まず TypeScript 上でアプリケーションで利用するデータ型の定義を行い、Firestore にあるドキュメントをそこに単純な対応でマッピングさせることで、アプリケーションの側からデータストアに対してスキーマを与える、という形になる。

TypeScript によるデータ型定義

Task という名前の型を定義する。

// ./data/task.ts
export type Task = {
  __type: 'task';
  id?: string;
  userId: string;
  createdAt?: Date; */ タスクの作成時刻
  done: boolean; /* タスクを完了したかどうか
  name: string; */ タスクの内容
  scheduledAt: Date | null; /* タスクの期限
};

解説

  • 実行時に型を判別するために、__type プロパティを導入した
  • データベースに保存された後に確定するプロパティである id や createdAt はオプショナルにしている
    • Task オブジェクトは、データベースに保存されるよりも前から存在できることを意図している
    • この判断は、create と update の両方を行い得るような React コンポーネントの実装を単純にする
  • userId はひとまず必要ないので忘れて良い
    • かなり後の方でコレクショングループという仕組みを使う際に解説する

Firestore のドキュメントとアプリケーションのデータ型との相互変換

この目的のために、FirestoreDataConverter という単純なインターフェイスが提供されている。toFirestorefromFirestore を持つことがこのインターフェイスの最小の要件である。

// ./data/task.ts
export const TaskConverter: FirestoreDataConverter<Task> = {
  toFirestore: (task) => {
    return {
      __type: 'task',
      userId: task.userId,
      done: task.done,
      name: task.name,
      scheduledAt: task.scheduledAt
        ? Timestamp.fromDate(task.scheduledAt),
      createdAt: task.createdAt
        ? Timestamp.fromDate(task.createdAt),
    };
  },
  fromFirestore: (sn) => {
    const data = sn.data();
    const task = {
      id: sn.id,
      ...data,
      scheduledAt: data.scheduledAt?.toDate(),
      createdAt: data.createdAt?.toDate(),
    } as Task;
    task.id = sn.id;
    return task;
  },
};

解説

  • 多くは見たままではあるが、以下のようなことをコンバーターでやることには価値があるだろう
    • 'firebase/firestore'Timestamp クラスと、JavaScript 組み込みの Date クラスの変換
    • データ追加時に createdAt のようなタイムスタムを自動で付与する
    • データ追加時・取得時のバリデーション
  • モジュール単位:データ型の定義と、コンバーターは同時に変更することが多いので、同じ ./data/task.ts に置いている

バリデーションについて少し Firestore での対応を悩んだのでメモ。Firestore はセキュリティルールがあり、ここでロジックがかけるので、バリデーションが可能である。

一方で、セキュリティルールに色々書いていくのは少々厳しいし、そもそも重要なドメインロジックが分散してしまうので、かなりアプリケーションが成長しない限りはクライアントサイドでのバリデーションをすることになるなるのだと思う。これは、サーバーはスキーマレスでクライアントサイド主導でデータ型などの「定義」をしていくということの延長戦上であるので、そのように理解すると良いのではないかと思った。

また、ユーザーごとにデータの読み書きの権限さえ分離しておけば、困るのは悪意を持ってコンソールなどから変なデータを混入したユーザーのみなので、事実上問題ないのだろう。

セキュリティルールの定義 ≒ コレクションの定義

一つ前のステップで Firestore のコレクションがどういうフィールドの集合を持つかは決まったので、表示させるためにコンソールから 2-3 件、データを追加してみる。ここでは、ユーザー(例:/users/2141)の直下に、タスクがあるという形でひとまず考えて、Firebase コンソールからポチポチとドキュメントとして追加する。

Firestore においてドキュメントに対応するコレクションはなければ暗黙的に作成されるし、コレクションのスキーマというものも存在しないので、リレーショナルデータベースでいうテーブル定義のような操作は一切必要ないのだが、「それを読み書きできるようにする」ということには明示的なセキュリティルールの変更が必要だ。そういう意味で、セキュリティルールが実質的なテーブル定義になる。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read, write: if request.auth.uid == userId;

      match /tasks/{taskId} {
        allow read, write: if request.auth.uid == userId;
      }
    }
  }
}

これを再度、 Firestore に投入する。

解説

上記では、ユーザーというコレクションがあり、その配下にタスクというコレクションがあるということを表現している。request.auth.uid には Authentication で認証したユーザーの ID が入っているので、自分自身のコレクションにしか読み書きができないようになっている。

Firestore からのデータ取得と表示

取得したタスクリストを React コンポーネントの状態として保存し、unordered list (ul + li) で表示してみよう。概形は次のようになる。

function App({}: AppProps) {
  const [maybeUser, loading] = useAuthState();
  const [tasks, setTasks] = useState<Task[]>([]);

  // `setTasks` を使った取得処理
  // ...

  return (
    <ul>
      {tasks.map((task) => (
        <li>{JSON.stringify(task)}</li>
      ))}
    </ul>
  );
}

setTasks を使った取得処理はこうなる。onSnapshot を用いて行う。

  useEffect(() => {
    if (!maybeUser) return;
    const userId = maybeUser.uid;

    const col = collection(
      firebaseFirestore,
      `/users/${userId}/tasks`,
    ).withConverter(TaskConverter);

    const unsubscribe = onSnapshot(col, {
      next: (sn) => {
        setTasks(sn.docs.map((docSn) => docSn.data()));
      },
    });
    return unsubscribe;
  }, [!!maybeUser, loading]);

これで、単純ではあるが以下のような表示が得られる。

なお、Firestore の振る舞いをよく理解するために、コンソールからドキュメントを追加してみると良い。即座に、React アプリケーションの表示内容も変化するはずだ。

解説

コレクションのパスに userId が必要なので、ユーザーをロードし終えたら取得処理を開始するように useEffect を定義している。

その後、collection の定義。データベースとパスを与えることで、CollectionReference<DocumentData> 型のオブジェクトとなる。ここに withConverter メソッドを使って事前に定義したコンバーターを噛ませることで、CollectionReference<Task> 型のオブジェクトに特殊化される。

これを onSnapshot で継続的に読み込み、変更があるたびにコールバックが呼ばれて setTasks で状態を更新する。docSn.data() を呼び出した結果はコンバーターの処理が自動で入っており Task 型のオブジェクトが取得できる。これがコンバーターを使う利点だ。

コンポーネント化してビューを実装する

通常の React の実装なので詳細は省略するが、最終的には Task オブジェクトを受け取ってそれをリストアイテムとして表示するコンポーネントにする。

todoist-clone/TaskItemView.tsx at master · Altech/todoist-clone

3. 未完了のタスクのみに絞り込んで最新順で表示する

ここでちょっと仕様を改善して、1) 完了したタスクは表示しないようにする、2) 追加した順序で表示するようにする、と言うことをやってみよう。そのためにコレクションに絞り込み処理を追加する必要がある。次のように4行追加する。

const col = collection(firebaseFirestore, `/users/${userId}/tasks`);
// 追加する処理
const q = query(
  where('done', '==', false),
  orderBy('createdAt'),
);
onSnapshot(q, { ... })

そうすると、コンソールに以下のようなエラーが出る。

インデックスを構築する必要があるということで、表示されているURLをクリックしてみる。するとインデックスの追加モーダルが準備されているので、ボタンを押すことでインデックスを追加できる。

インデックスの構築は数分時間がかかるので待ってから再実行すると、意図通りに動くようになる。

解説

基本的には、単一のフィールドに対するインデックスは貼ってあるので、それ以外の新しいクエリパターンを追加する際に、怒られて随時インデックスを追加する、と言う形になる。

デフォルトでは、Cloud Firestore はドキュメント内のフィールドおよびマップ内のサブフィールドごとに単一フィールド インデックスを自動的に維持します。Cloud Firestore では単一フィールド インデックスに次のデフォルト設定が使用されます。

Cloud Firestore のインデックスの種類 | Firebase

4. タスクを追加・編集できるようにする

addDocsetDoc を使ってできる。要点である追加・編集をユーザーが行った時のハンドラーのコードは以下のようになる。

    const col = collection(firebaseFirestore, `/users/${userId}/tasks`).withConverter(
      TaskConverter,
    );
    if (task.id) {
      setDoc(doc(col, task.id), task)
        .then((_task) => props.onComplete())
        .catch((e) => console.error(e));
    } else {
      addDoc(col, task)
        .then((_docRef) => props.onComplete())
        .catch((e) => console.error(e));
    }

ここでも、withConverter を使ってコンバーターを噛ませている。これにより、 JavaScript オブジェクト → Firestore ドキュメントの方向のデータ投入が隠蔽され、かつ型で保証される。

React コンポーネントの全体像は https://github.com/Altech/todoist-clone/blob/master/src/TaskItemForm.tsx のようになった。

解説

上記のコード自体はコンバーターを使うこと以外はあまり特徴はなく、「まあそんなものかな」という印象を受けるかもしれない。

局所的に見ると事実そうなのだが、アプリケーション全体で見ると、ここで投入したデータを他の色々なコンポーネントで参照していても、onSnapshot で自動的に読み込まれるということに価値がある。データを追加したのでリストを再取得するという処理が付随する、というのは従来よくあることだが、そういったことは考える必要はない。今回作ったアプリでも、別途各プロジェクトをタスクを監視して件数を表示しているが、これも自動的に整合性が取れる。

バックエンドのデータの変更に対してのデータのキャッシュや再取得のタイミングを一切考える必要がなくなる というのが、onSnapshot のようなサブスクリプションベースの取得方式の特長だ、と言えるだろう。

このことはアプリケーションの設計を大きく単純にするが、さらにそれが体験上の利点をもたらす可能性がある。つまり、 再取得やデータの同期に設計上のコストを払わなくて良いのなら、今すぐ表示する必要のないデータも投機的に取得しておくという選択肢が取りやすくなる ということだ。例えば、Todoist のような TODO 管理アプリであれば、インボックスに入っているタスクだけでなく、プロジェクトごとのタスクも全部最初からサブスクライブしておけば、画面を切り替えたときにロードが走らずに済む。

これは Firestore が SaaS として提供されていてスケーラビリティが十分にある上で、従量課金性になっていることとも非常に相性が良い。なぜなら、金銭的なコストとユーザー体験の向上を自由にトレードオフすることができるからだ。事前に取得することによる、設計コストの増加は従来に比べて非常に少ない。

https://firebase.google.com/docs/firestore/pricing?hl=ja

5. プロジェクトごとにタスクを追加できるようにする

新たに作成したタスクはデフォルトでは「インボックス」というところに入る。今まではこれを想定してユーザーの直下にタスクを追加してきた。ここで新しい機能として、プロジェクトにもタスクを追加できるようにしたい。

プロジェクト自体を追加したり、表示したりする要領は全く同じなので省略する。ここで悩むのは、Firestore のデータ設計だ。ユーザーの直下に全てのタスクを置いてプロジェクトIDをフィールドとして持たせるのか、それともユーザーの直下にプロジェクトを置いて、その下にタスクを配置するのか。

結論から言うと、インボックスのタスクはユーザー直下のままで、それ以外はプロジェクトの下にタスクを持たせるようにデータ設計を拡張した。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read, write: if request.auth.uid == userId;

      match /tasks/{taskId} {
        allow read, write: if request.auth.uid == userId;
      }

      match /projects/{projectId} {
        allow read, write: if request.auth.uid == userId;

        match /tasks/{taskId} {
          allow read, write: if request.auth.uid == userId;
        }
      }
    }
  }
}

解説

Firestore のデータ設計について言えることは複数あるが(scrap に雑にだがまとめた)、Firestore のデータは、親となるドキュメントが削除された場合、その子供のコレクションも自動的に削除されると言う挙動になっている。データのライフサイクルに上手く活かすことで実装が単純になる。

この場合、プロジェクトを削除したらプロジェクトのタスクも全て削除されると言う挙動が構造の定義から自動的に保証される。また言うまでもなく、ユーザーが削除されたらユーザーのデータも全て削除される。

6. 「近日予定」フィルターを実装する

最後に、「近日予定」フィルターを実装する。これは、インボックスやプロジェクトの全てのタスクから横断的に、明日以降にスケジュールされているタスクをフィルターして表示する機能だ。

Firestore では通常、ある一つのコレクションに対しての絞り込みしかできない。ほとんどのケースではそれで済むし、逆にそれで済むようにデータ設計をするべきなのだが、そこから漏れた場合にはどのようにするか。ここでコレクショングループ(collectionGroup)と言う新しい概念と関数を使うことになる。

collectionGroup が返すオブジェクトはクエリの一種だが、コレクションに横断的なクエリになる。collectionGroup('tasks') のように呼ぶことで、Firestore 中の全ての tasks と言う名前のコレクションがクエリの対象となる。

ただし、そのクエリを onSnapshot などで実行しても、権限がないと言われる。これは、このままだと他のユーザーのタスクも対象となってしまうためだ(逆に言えば、サービス運営者がサービス上の全てのデータを集計する場合にはそれが使えるということだ)。

そういうわけで、where 条件によって userID を絞り込みたくなるのだが、絞り込み対象にできるのは対象となるドキュメントに含まれるフィールドのみである。こういうことがあるので、やや迂遠ではあるが、すべてのドキュメントには userId のような絞り込みと権限チェックに必要なフィールドを個別に埋め込んで置くこと、が解決方法となる(これが Task 型に userId を最初から埋め込んでいた理由だ)。

    const colGr = collectionGroup(db, 'tasks').withConverter(TaskConverter);
    const q = query(
      colGr,
      where('userId', '==', userId),
      where('done', '==', false),
      where('scheduledAt', '>', new Date()),
      orderBy('scheduledAt'),
    );
    onSnapshot(q, { ... })

この場合、セキュリティルールも僅かに変更が必要だ。異なるユーザーIDをセットすることができないように、リソースの作成・更新処理においてバリデーションを追加する。

      match /tasks/{taskID} {
        allow read, delete: if request.auth.uid == userId
        allow create, update: if request.auth.uid == userId && request.resource.data.userId == userId;
      }

なお、前のセクションと同様新しいクエリパターンのため、コンソールに表示される URL からインデックスを貼りに行く必要がある。

7. デプロイする

さて、ここまで作ったものを公開してみたい。そういう機能もあると期待するし、そこまでワンセットでできないのならソリューションとしては十分ではないと思う。

調べてみると、Firebase Hosting という機能があるらしく、Firebase の CLI を使って操作できるようだ。

Firebase CLI reference に従って、以下のコマンドでインストール。

$ curl -sL https://firebase.tools | bash

コマンドがインストールされる。

$ firebase login

ブラウザが開いて Google アカウントを選択し、認証。

$ firebase init

連携する機能を選ぶことになる。今回は Hosting のみを選択。

? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices. (Press <space> to select, <a> to toggle all, <i> to invert selection)
❯◯ Database: Deploy Firebase Realtime Database Rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
 ◯ Functions: Configure and deploy Cloud Functions
 ◯ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules
 ◯ Emulators: Set up local emulators for Firebase features   

何か色々聞かれるので、言われたことに応えていくと firebase.json と言う設定ファイルができる。public のプロパティが配信するディレクトリっぽいので、ここを "build" に書き換えた(元々は "public")。

{
  "hosting": {
    "public": "build",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [{
      "source": "**",
      "destination": "/index.html"
    }]
  }
}

後は snowpack のビルドコマンドを呼び出してデプロイ。

$ yarn build
$ firebase deploy

公開された 🎉

https://altech-todoist.web.app/

まとめ

最後は駆け足になってしまったが、Firestore を使って React アプリケーションを作る際にどういった手続きと記述になるのかをまとめた。リアクティブプログラミングの要素と、スキーマレスでサブスクリプションAPIを持つデータベースと、セキュリティルールと、少し変わった要素を組み合わせてどうなるかをまとめてみた。

作ってみた過程で学んだことは網羅したが、一つ取り上げられなかったのが Cloud Function だ。Todoist でいえば「毎週新しいタスクを繰り返し設定する」などの機能をつけるためにはこういったものを併せて使うことになるだろう。使う機会があったらまた記事にまとめてみたい。

Discussion