Series: react-firestore

React で Firestore のデータを読み取ってページングする

前回の続きで、ページングを実装する。

開発環境

  • node v16.6.0
  • npm v7.19.1
  • react v17.0.2

本文

Firestore はあまりページングに向いてないので本当は無限スクロールを実装したいが、使おうとしたライブラリが React v17 に対応していなかったので、とりあえず「次へ」ボタンでページングするところまで実装した。

ソースコードは 前回 の状態からスタートする。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import React from 'react';
import './App.css';
import firebase from "firebase/app";
import "firebase/firestore";
import dayjs from 'dayjs';

const firebaseConfig = {
  // 自分のプロジェクトの内容に差し替える
};

firebase.initializeApp(firebaseConfig);

const db = firebase.firestore();

class Blog extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      items: []
    };
  }

  componentDidMount() {
    const items = [];

    db.collection("posts").get().then((querySnapshot) => {
      querySnapshot.forEach((doc) => {
        let item = doc.data();
        item.id = doc.id;
        items.push(item);
      });
      this.setState({ items: items });
    });
  }

  render() {
    const items = this.state.items;

    return (
      <ol>
        {items.map((item) => {
          const created = dayjs(item.created.toDate());
          const updated = dayjs(item.updated.toDate());
          return (
            <li key={item.id}>
              <div>{item.id}</div>
              <div className="App-text">{item.text}</div>
              <div>
                <time dateTime={created.format('YYYY-MM-DDTHH:mm:ss')}>
                  {created.format('YYYY-MM-DD HH:mm:ss')}
                </time>
              </div>
              <div>
                <time dateTime={updated.format('YYYY-MM-DDTHH:mm:ss')}>
                  {updated.format('YYYY-MM-DD HH:mm:ss')}
                </time>
              </div>
            </li>
          );
        })}
      </ol>
    );
  }
}

function App() {
  return (
    <div>
      <main>
        <Blog />
      </main>
    </div>
  );
}

export default App;

ドキュメントスナップショットを使用したクエリのページ設定

クエリカーソルを使用したデータのページ設定 を見て、まずドキュメントスナップショットを用いてクエリのページ設定をする。

データを五個取って最後のデータをドキュメントスナップショットとして lastItem に保存し、次のデータが存在するかどうかを調べて hasNext に保存する。

14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// 14 行目まで省略
class Blog extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      items: [],
      lastItem: null,
      hasNext: false
    };
  }

  async getItems() {
    const items = [];
    const lastItem = this.state.lastItem;
    const hasNext = this.state.hasNext;
    let first;
    if (hasNext) {
      first = await db.collection("posts").orderBy('created', 'desc').startAfter(lastItem.data().created).limit(5);
    } else {
      first = await db.collection("posts").orderBy('created', 'desc').limit(5);
    }
    const snapshot = await first.get();
    const last = snapshot.docs[snapshot.docs.length - 1];
    const next = await db.collection("posts").orderBy('created', 'desc').startAfter(last.data().created).limit(1);
    const nextSnapshot = await next.get();
    snapshot.forEach((doc) => {
      let item = doc.data();
      item.id = doc.id;
      items.push(item);
    });
    this.setState({
      items: items,
      lastItem: last,
      hasNext: nextSnapshot.docs.length > 0 ? true : false
    });
  }

  async componentDidMount() {
    await this.getItems();
  }

  render() {
    const items = this.state.items;

    return (
      <ol>
        {items.map((item) => {
          const created = dayjs(item.created.toDate());
          const updated = dayjs(item.updated.toDate());
          return (
            <li key={item.id}>
              <div>{item.id}</div>
              <div className="App-text">{item.text}</div>
              <div>
                <time dateTime={created.format('YYYY-MM-DDTHH:mm:ss')}>
                  {created.format('YYYY-MM-DD HH:mm:ss')}
                </time>
              </div>
              <div>
                <time dateTime={updated.format('YYYY-MM-DDTHH:mm:ss')}>
                  {updated.format('YYYY-MM-DD HH:mm:ss')}
                </time>
              </div>
            </li>
          );
        })}
      </ol>
    );
  }
}

function App() {
  return (
    <div>
      <main>
        <Blog />
      </main>
    </div>
  );
}

export default App;

これで画面には最初の五個のデータが表示されただけの状態になるが、React Developer Tools で見ると lastItem と hasNext に値が入っていることがわかる。

ページング用ボタンの表示

とりあえず押しても何も怒らない「次へ」ボタンを表示する。

 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
// 14 行目まで省略
class Blog extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      items: [],
      lastItem: null,
      hasNext: false
    };
  }

  async getItems() {
    const items = [];
    const lastItem = this.state.lastItem;
    const hasNext = this.state.hasNext;
    let first;
    if (hasNext) {
      first = await db.collection("posts").orderBy('created', 'desc').startAfter(lastItem.data().created).limit(5);
    } else {
      first = await db.collection("posts").orderBy('created', 'desc').limit(5);
    }
    const snapshot = await first.get();
    const last = snapshot.docs[snapshot.docs.length - 1];
    const next = await db.collection("posts").orderBy('created', 'desc').startAfter(last.data().created).limit(1);
    const nextSnapshot = await next.get();
    snapshot.forEach((doc) => {
      let item = doc.data();
      item.id = doc.id;
      items.push(item);
    });
    this.setState({
      items: items,
      lastItem: last,
      hasNext: nextSnapshot.docs.length > 0 ? true : false
    });
  }

  async componentDidMount() {
    await this.getItems();
  }

  render() {
    const items = this.state.items;

    return (
      <ol>
        {items.map((item) => {
          const created = dayjs(item.created.toDate());
          const updated = dayjs(item.updated.toDate());
          return (
            <li key={item.id}>
              <div>{item.id}</div>
              <div className="App-text">{item.text}</div>
              <div>
                <time dateTime={created.format('YYYY-MM-DDTHH:mm:ss')}>
                  {created.format('YYYY-MM-DD HH:mm:ss')}
                </time>
              </div>
              <div>
                <time dateTime={updated.format('YYYY-MM-DDTHH:mm:ss')}>
                  {updated.format('YYYY-MM-DD HH:mm:ss')}
                </time>
              </div>
            </li>
          );
        })}
      </ol>
    );
  }
}

function NextButton(props) {
  const hasNext = props.hasNext;
  if (hasNext) {
    return <button>次へ</button>;
  }
  return null;
}

function App() {
  return (
    <div>
      <main>
        <Blog />
        <NextButton hasNext={true} />
      </main>
    </div>
  );
}

export default App;

これで画面上には最初の五個のデータと「次へ」ボタンが出た状態になる。「次へ」ボタンは押しても何も起こらない。

State のリフトアップ

「次へ」ボタンを押したらページングするようにしたいが、今のままだとデータを Blog コンポーネントが持っているので難しい。そのため親コンポーネントがデータを持つようにする。

下記のように実装を変更する。

  • 新たに Main というクラスを作成し、今まで Blog でやっていたデータ読み込み等を移植する。
  • Blog は props で items を受け取って表示するだけにする。それに伴い関数コンポーネントにする。
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
// 14 行目まで省略
function Blog(props) {
  const items = props.items;
  return (
    <ol>
      {items.map((item) => {
        const created = dayjs(item.created.toDate());
        const updated = dayjs(item.updated.toDate());
        return (
          <li key={item.id}>
            <div>{item.id}</div>
            <div className="App-text">{item.text}</div>
            <div>
              <time dateTime={created.format('YYYY-MM-DDTHH:mm:ss')}>
                {created.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
            <div>
              <time dateTime={updated.format('YYYY-MM-DDTHH:mm:ss')}>
                {updated.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
          </li>
        );
      })}
    </ol>
  );
}

function NextButton(props) {
  const hasNext = props.hasNext;
  if (hasNext) {
    return <button>次へ</button>;
  }
  return null;
}

class Main extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      items: [],
      lastItem: null,
      hasNext: false
    };
  }

  async getItems() {
    const items = [];
    const lastItem = this.state.lastItem;
    const hasNext = this.state.hasNext;
    let first;
    if (hasNext) {
      first = await db.collection("posts").orderBy('created', 'desc').startAfter(lastItem.data().created).limit(5);
    } else {
      first = await db.collection("posts").orderBy('created', 'desc').limit(5);
    }
    const snapshot = await first.get();
    const last = snapshot.docs[snapshot.docs.length - 1];
    const next = await db.collection("posts").orderBy('created', 'desc').startAfter(last.data().created).limit(1);
    const nextSnapshot = await next.get();
    snapshot.forEach((doc) => {
      let item = doc.data();
      item.id = doc.id;
      items.push(item);
    });
    this.setState({
      items: items,
      lastItem: last,
      hasNext: nextSnapshot.docs.length > 0 ? true : false
    });
  }

  async componentDidMount() {
    await this.getItems();
  }

  render() {
    const items = this.state.items;
    const hasNext = this.state.hasNext;
    return (
      <main>
        <Blog items={items}/>
        <NextButton hasNext={hasNext} />
      </main>
    );
  }
}

function App() {
  return (
    <div>
      <Main />
    </div>
  );
}

export default App;

これで画面上の表示は何も変わっていないが Blog と NextButton の親がデータを持つようになった。

ページングする

最後に NextButton を押下したときに次のデータを読み込むようにする。

 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
// 14 行目まで省略
function Blog(props) {
  const items = props.items;
  return (
    <ol>
      {items.map((item) => {
        const created = dayjs(item.created.toDate());
        const updated = dayjs(item.updated.toDate());
        return (
          <li key={item.id}>
            <div>{item.id}</div>
            <div className="App-text">{item.text}</div>
            <div>
              <time dateTime={created.format('YYYY-MM-DDTHH:mm:ss')}>
                {created.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
            <div>
              <time dateTime={updated.format('YYYY-MM-DDTHH:mm:ss')}>
                {updated.format('YYYY-MM-DD HH:mm:ss')}
              </time>
            </div>
          </li>
        );
      })}
    </ol>
  );
}

function NextButton(props) {
  const hasNext = props.hasNext;
  if (hasNext) {
    return <button onClick={() => props.onClick()}>次へ</button>;
  }
  return null;
}

class Main extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      items: [],
      lastItem: null,
      hasNext: false
    };
  }

  async getItems() {
    const items = [];
    const lastItem = this.state.lastItem;
    const hasNext = this.state.hasNext;
    let first;
    if (hasNext) {
      first = await db.collection("posts").orderBy('created', 'desc').startAfter(lastItem.data().created).limit(5);
    } else {
      first = await db.collection("posts").orderBy('created', 'desc').limit(5);
    }
    const snapshot = await first.get();
    const last = snapshot.docs[snapshot.docs.length - 1];
    const next = await db.collection("posts").orderBy('created', 'desc').startAfter(last.data().created).limit(1);
    const nextSnapshot = await next.get();
    snapshot.forEach((doc) => {
      let item = doc.data();
      item.id = doc.id;
      items.push(item);
    });
    this.setState({
      items: items,
      lastItem: last,
      hasNext: nextSnapshot.docs.length > 0 ? true : false
    });
  }

  async componentDidMount() {
    await this.getItems();
  }

  render() {
    const items = this.state.items;
    const hasNext = this.state.hasNext;
    return (
      <main>
        <Blog items={items}/>
        <NextButton hasNext={hasNext} onClick={() => this.getItems()}/>
      </main>
    );
  }
}

function App() {
  return (
    <div>
      <Main />
    </div>
  );
}

export default App;

これで「次へ」ボタンを押すと次のデータが読み込まれ、次のデータが無くなると「次へ」ボタンが表示されなくなる。

今回はここまで。