LoginSignup

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

More than 3 years have passed since last update.

React + Firestoreで普通の?Paginationをしてみる(基礎編)

Last updated at Posted at 2019-11-21

React + FirestoreでInfinit Scrollによるページングはよくやるのですが、[次へ], [戻る]で遷移する普通の?ページングをやってみたら以外としんどかったのでメモしておきます。

検索機能等を実装した応用編もどうぞ。

完成品

下記のような感じです。わかりにくいですが下の方に[<],[>]ボタンが付いてます。

スクリーンショット 2019-11-22 8.47.33.png

前提条件

  • Firesbase(Firestore)の利用準備はできている(firestoreが使える状態)。
  • 秘密鍵等をダウンロードできる/できている(この意味がわかる)。
  • create-react-appを利用できる状態になっている。
  • 私はMacで作業してますがWindowsでもあまり変わらないと思います。

秘密鍵のダウンロードについてはこちらが参考になります(最初の方だけ)。

テストデータの生成

まず、ページングをテストするために表示用のテストデータを作成します。これはReactと関係ありません。
下記のような感じでフォルダ、ファイルを作ります。ローカル(node.js)でfirestoreを使うためにfirebase-adminを使います。

mkdir generate
cd generate
touch index.js

npm install --save firebase-admin

index.jsを下記のようにします。とりあえず100件のデータを生成してみます。
項目とかは最低限のテストができる内容ですが、お好みで変更するといいと思います。

1つポイント?があるとするなら、検索機能実装用にkeywordsという各ドキュメント内容を配列で格納するフィールドを作成しているところでしょうか。これをどう使うかは後々(応用編で利用)わかります。

/path/to/key.jsonは各自の環境に合わせた秘密鍵ファイルを取得・利用してください。FirebaseのWeb画面で取得します。

index.js
const admin = require('firebase-admin');
const serviceAccount = require('/path/to/key.json');

admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
    databaseURL: 'https://project-id.firebaseio.com',
});

const db = admin.firestore();

//emitt warning防止
require('events').EventEmitter.defaultMaxListeners = 0;

//データ生成
for (let i = 1; i <= 100; i++) {

    const docId = db.collection('members').doc().id;
    const memberId = ('000000000' + i).slice(-10);
    const name = "user" + i;
    const email = "user" + i + "@test.com";
    const age = Math.floor(Math.random() * 60) + 15;

    const areas = ['東京', '大阪', '福岡', '仙台', '札幌'];
    const address = areas[Math.floor(Math.random() * areas.length)];

    db.collection('members').doc(docId).set({
        docId: docId,
        memberId: memberId,
        name: name,
        email: email,
        age: age,
        address: address,
        createdAt: admin.firestore.FieldValue.serverTimestamp(),
        keywords: [docId, memberId, name, email, age.toString(), address],
        id: i,
    });

    console.log("create data no" + i);
}

実行して生成します。

node index.js

データが正しく生成されているかどうかFirestoreのWeb consoleで確認してみてください。

開発前の準備

React雛形の作成 + モジュールのインストール

とりあえず雛形を作ります。
Firebaseを使うのでfirebaseもインストールします。
またレイアウトにreactstrapを使うのでインストールします。
時間の表示形式を整えるためにmomentもインストールします。

create-react-app pagination
cd pagination

yarn add firebase bootstrap reactstrap moment

yarnでない場合はnpm install --save bootstrap reactstrap firebase moment としてください。また、スタイルにreactstrapを使うのはCoreUI for React等でのスタイリングに利用されているからです(ノウハウの汎用化のため)。

Firebase機能を使うための準備

ReactでFirebaseの機能を利用するためにFirebaseの設定・生成ファイルを作成しておきます。
なお、create-react-appでは標準でdotenvを利用できるので、設定項目は.envに記述することにします。

まず、それぞれのファイルを作ります。

touch .env
touch src/Firebase.js

.envをgithubとかに公開しないように.gitignoreで.envを登録しておきましょう。

.env

xxxxに相当する値はFirebase Web Consoleで確認してください。

REACT_APP_API_KEY=xxxxx
REACT_APP_AUTH_DOMAIN=xxxx
REACT_APP_DATABASE_URL=xxxx
REACT_APP_PROJECT_ID=xxxx
REACT_APP_STORAGE_BUCKET=xxxx
REACT_APP_MESSAGING_SENDER_ID=938033440605
REACT_APP_APP_ID=xxxx

src/Firebase.js

.envの内容を利用してconfig内容を記述します。

import firebase from 'firebase/app';
import "firebase/firestore";

const firebaseConfig = {
    apiKey: process.env.REACT_APP_API_KEY,
    authDomain: process.env.REACT_APP_AUTH_DOMAIN,
    databaseURL: process.env.REACT_APP_DATABASE_URL,
    projectId: process.env.REACT_APP_PROJECT_ID,
    storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
    messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
    appId: process.env.REACT_APP_APP_ID
};

firebase.initializeApp(firebaseConfig);

export const db = firebase.firestore();
export default firebase;

これでfirebaseの機能を呼び出して利用する準備が整いました(そのはず)。

bootstrap cssの適用

index.jsでbootstrapのcssを読み込んでおきます。

index.js
import React from 'react';
import ReactDOM from 'react-dom';
+import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';

ReactDOM.render(<App />, document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

Paginationの実装方針(いいわけ)

Paginationの実装は、firestoreのクエリでLimitやStartAfter(), StartAt()等を組み合わせて実現するのですが、なかなか良い方法が見つからず冗長なコードになっています。

今回の実装では、初期データの取得、「次」のデータの取得、「前」のデータの取得ごとに専用の関数を作成しました。

  • getData()で初期に表示するデータを取得(表示)
  • getNext()で次のページのデータを取得(表示)
  • getPrev()で前のページのデータを取得(表示)

もちろんgetData(start位置,end位置)みたいに1つの関数にしたかったのですが、なかなか良い方法が見つかりませんでした。

随時改良・更新したいと思います。

実装1:ひとまず情報を表示してみる

実装はApp.jsに対して行ってみます。
では、ひとまず表示してみます。

App.js
import React from 'react';
import './App.css';
import { db } from './Firebase';
import moment from 'moment';

import { Table, Form, FormGroup, Label, Input, Button, Pagination, PaginationItem, PaginationLink } from 'reactstrap';

class App extends React.Component {

    state = {
        items: [], //検索結果のいれもの
    }

    componentDidMount = () => {
        this.getData();
    }

    //初期データ取得用
    getData = async () => {

        //クエリ
        const initialQuery = db.collection('members')
            .orderBy('createdAt', 'desc')
            .limit(10);

        //取得
        const snapshot = await initialQuery.get();

        //各行を取得
        const docs = snapshot.docs.map(doc => doc.data());

        //state更新
        this.setState({
            items: docs,
        });

    }

    render() {
        return (
            <div className="container">
                <h3 className="my-4">Pagination sample.</h3>
                <Table striped>
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>UID</th>
                            <th>Name</th>
                            <th>Age</th>
                            <th>Address</th>
                            <th>CreatedAt</th>
                        </tr>
                    </thead>
                    <tbody>
                        {
                            this.state.items.map(doc => (
                                <tr key={doc.docId}>
                                    <td>{doc.id}</td>
                                    <td>{doc.docId}</td>
                                    <td>{doc.name}</td>
                                    <td>{doc.age}</td>
                                    <td>{doc.address}</td>
                                    <td>{moment(doc.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}</td>
                                </tr>
                            ))
                        }
                    </tbody>
                </Table>

                <Pagination>
                    <PaginationItem>
                        <PaginationLink previous className="mr-3"/>
                    </PaginationItem>
                    <PaginationItem>
                        <PaginationLink next />
                    </PaginationItem>
                </Pagination>

            </div>
        );
    }
}

export default App;

確認してみます。

yarn start

npmの人はnpm start。

実装2:Pagination(Next:次へ)を実装する

次にNextボタンを機能させてみたいと思います。

App.js
import React from 'react';
import './App.css';
import { db } from './Firebase';
import moment from 'moment';

import { Table, Form, FormGroup, Label, Input, Button, Pagination, PaginationItem, PaginationLink } from 'reactstrap';

class App extends React.Component {

    state = {
        items: [], //検索結果
+        limit: 10,
+        lastVisible: null, //次のページの表示位置を保存するいれもの
    }

    componentDidMount = () => {
        this.getData();
    }

    //初期データ取得用
    getData = async () => {

        //クエリ
        const initialQuery = db.collection('members')
            .orderBy('createdAt', 'desc')
+            .limit(this.state.limit); //limit値をsatteから取得

        //取得
        const snapshot = await initialQuery.get();

        //各行を取得
        const docs = snapshot.docs.map(doc => doc.data());

        //最後の行(オブジェクトを記憶しておく)
+        const lastVisible = snapshot.docs[docs.length - 1];

        //state更新
        this.setState({
            items: docs,
+            lastVisible: lastVisible,
        });

    }

+   //次のデータを取得(以下、関数全体を追加)
    getNextData = async () => {

        const nextQuery = db.collection('members')
            .orderBy('createdAt', 'desc')
            .startAfter(this.state.lastVisible) //記録してある前回の最後以降から取得する
            .limit(this.state.limit);

        const snapshot = await nextQuery.get();

        //データが1つ以上なければ何もしない(最後のページ対策)
        if (snapshot.size < 1) {
            return null;
        }

        const docs = snapshot.docs.map(doc => doc.data());

        const lastVisible = snapshot.docs[docs.length - 1];

        this.setState({
            items: docs,
            lastVisible: lastVisible,
        });
    }

+    //nextがクリックされたとき(以下、関数全体を追加)
    handleNext = () => {
        this.getNextData();
    }

    render() {
        return (
            <div className="container">
                <h3 className="my-4">Pagination sample.</h3>
                <Table striped>
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>UID</th>
                            <th>Name</th>
                            <th>Age</th>
                            <th>Address</th>
                            <th>CreatedAt</th>
                        </tr>
                    </thead>
                    <tbody>
                        {
                            this.state.items.map(doc => (
                                <tr key={doc.docId}>
                                    <td>{doc.id}</td>
                                    <td>{doc.docId}</td>
                                    <td>{doc.name}</td>
                                    <td>{doc.age}</td>
                                    <td>{doc.address}</td>
                                    <td>{moment(doc.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}</td>
                                </tr>
                            ))
                        }
                    </tbody>
                </Table>

                <Pagination>
                    <PaginationItem>
                        <PaginationLink previous className="mr-3" />
                    </PaginationItem>
                    <PaginationItem>
+                        <PaginationLink next onClick={this.handleNext} />
                    </PaginationItem>
                </Pagination>

            </div>
        );
    }
}

export default App;

nextボタンが機能するようになりました。

実装3:Pagination(Prev:戻る)を実装する

App.js
import React from 'react';
import './App.css';
import { db } from './Firebase';
import moment from 'moment';

import { Table, Form, FormGroup, Label, Input, Button, Pagination, PaginationItem, PaginationLink } from 'reactstrap';

class App extends React.Component {

    state = {
        items: [], //検索結果
        limit: 10,
        lastVisible: null,
+        history: [], //戻る履歴を入れる
    }

    componentDidMount = () => {
        this.getData();
    }

    //初期データ取得用
    getData = async () => {

        //クエリ
        const initialQuery = db.collection('members')
            .orderBy('createdAt', 'desc')
            .limit(this.state.limit);

        //取得
        const snapshot = await initialQuery.get();

        //各行を取得
        const docs = snapshot.docs.map(doc => doc.data());

        //最後の行(オブジェクトを記憶しておく)
        const lastVisible = snapshot.docs[docs.length - 1];

+        //Prevでの戻り先をhistoryに記憶
+        const startVisible = snapshot.docs[0];
+        let history = [...this.state.history];
+        history.push(startVisible);

        //state更新
        this.setState({
            items: docs,
            lastVisible: lastVisible,
+            history: history,
        });

    }

    //次のデータを取得
    getNextData = async () => {

        const nextQuery = db.collection('members')
            .orderBy('createdAt', 'desc')
            .startAfter(this.state.lastVisible) //記録してある前回の最後以降から取得する
            .limit(this.state.limit);

        const snapshot = await nextQuery.get();

        //データが1つ以上なければ何もしない(最後のページ対策)
        if (snapshot.size < 1) {
            return null;
        }

        const docs = snapshot.docs.map(doc => doc.data());

        const lastVisible = snapshot.docs[docs.length - 1];

+        //Prevでの戻り先をhistoryに記憶
+        const startVisible = snapshot.docs[0];
+        let history = [...this.state.history];
+        history.push(startVisible);

        this.setState({
            items: docs,
            lastVisible: lastVisible,
+            history: history,
        });
    }

+   //前のデータを取得(関数全体を追加)
    getPrevData = async () => {

        if (this.state.history.length <= 1) {
            return null;
        }

        const prevQuery = db.collection('members')
            .orderBy('createdAt', 'desc')
            .startAt(this.state.history[this.state.history.length - 2]) //最後から2つ目のページに戻る
            .limit(this.state.limit);

        const snapshot = await prevQuery.get();

        if (snapshot.size < 1) {
            return null;
        }

        const docs = snapshot.docs.map(doc => doc.data());

        const lastVisible = snapshot.docs[docs.length - 1];

        //戻る際に最後のhistoryを削除
        const history = [...this.state.history];
        history.pop();

        this.setState({
            items: docs,
            lastVisible: lastVisible,
            history: history,
        });
    }

    //nextがクリックされたとき
    handleNext = () => {
        this.getNextData();
    }

+    //prevがクリックされたとき(関数全体を追加)
    handlePrev = () => {
        this.getPrevData();
    }

    render() {
        return (
            <div className="container">
                <h3 className="my-4">Pagination sample.</h3>
                <Table striped>
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>UID</th>
                            <th>Name</th>
                            <th>Age</th>
                            <th>Address</th>
                            <th>CreatedAt</th>
                        </tr>
                    </thead>
                    <tbody>
                        {
                            this.state.items.map(doc => (
                                <tr key={doc.docId}>
                                    <td>{doc.id}</td>
                                    <td>{doc.docId}</td>
                                    <td>{doc.name}</td>
                                    <td>{doc.age}</td>
                                    <td>{doc.address}</td>
                                    <td>{moment(doc.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}</td>
                                </tr>
                            ))
                        }
                    </tbody>
                </Table>

                <Pagination>
                    <PaginationItem>
+                        <PaginationLink previous className="mr-3" onClick={this.handlePrev} />
                    </PaginationItem>
                    <PaginationItem>
                        <PaginationLink next onClick={this.handleNext} />
                    </PaginationItem>
                </Pagination>

            </div>
        );
    }
}

export default App;

とりあえずPaginationは一旦動きます。

考察

検索ヒット数やページ数を表示すべきか否か?

全体の検索ヒット数やページ数を表示するためには全てのレコードを一度取得する必要がある(全てをメモリにロードする)ため、Firebase等のNoSQLを利用する場合は極力「表示なし」にしたほうがいいかなという感じ(特にログ系とか)。

一方、レコード数が1000件以下程度であることが明確なコレクション(テーブル)に関してはヒット数やページ数を計算・表示してもいいかもしれません。

参考:https://www.sukerou.com/2019/08/firestore.html

応用編

  • 実装4:検索機能をつける
  • 実装5:CSVダウンロード機能をつける
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up