masalibの日記

システム開発、運用と猫の写真ブログです

React Firebase入門 Firestoreのページネーション処理

Firestoreの簡単な一覧、作成、更新、削除の処理はできたのですが一覧のページネーションがない状況なので ページネーションを追加しました。

この記事はReact Firebase入門シリーズです 1-1・ Firebase初期設定とFirebaseAuthのSignUp
1-2・ 「react-hook-form」を入れてみた
1-3・ AuthのSignUpの通信エラー対応
2 ・ FirebaseAuthのログイン処理
3 ・ FirebaseAuthのログイン認証とログアウト処理
4 ・ FirebaseAuthのパスワード初期化処理
5 ・ FirebaseAuthのメールアドレスの有効化
6 ・ FirebaseAuthのメールアドレスとパスワード変更
7 ・ FirebaseAuthの表示名変更
8-1・ FirebaseAuthのアバター変更:基礎
8-2・ FirebaseAuthのアバター変更:画像切り抜き
9-1・FirestoreのCRUD
9-2・Firestoreのデータ取得補足
9-3・Firestoreのページネーション処理 今ここ

ページネーションとは

次のページに遷移したり前のページしたりする事をページネーションといい、大きくわけて2つのやり方があります

  • オフセットベース(Offset-based pagination)
  • カーソルベース(Cursor-based pagination)

簡単なオフセットベースのページネーションを作りたかったがoffsetがなかった・・・
公式には書いてあったけど、console.logで確認したがなかった。

(´;ω;`)

しかもページネーションで一番大切な現在のページやレコード数も取れない事が判明。

本来はこのページネーションを作りたいのですが今回は次前のページリンクで逃げました

ページネーションの修正内容

前回のソースから改変した部分を記載したいと思います。

masalib.hatenablog.com

プログラム

index.js(一覧処理)しか直していません。 全部をみる人は少ないと思いますが念のための記載します

全文(長文なの注意してください)

import React, { useState, useEffect,useRef } from "react";
import  { db }  from "../../firebase"
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import {
    Typography,
    Table,          
    TableBody,      
    TableCell,      
    TableContainer, 
    TableHead,      
    TableRow,       
    Paper,          
+     Fab,
  } from '@material-ui/core';
import { Link } from "react-router-dom"
import moment from 'moment';
+ import {
+     NavigateBefore,
+     NavigateNext
+  } from "@material-ui/icons";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
        padding: 16, 
        margin: 'auto',
        maxWidth: 480
    },
    table: {
        margin: 'auto',
        minWidth: 600,
        maxWidth: 960

    },
+     largeIcon: {
+         width: 60,
+         height: 60,
+     },
  })
);

+ const LIMIT_COUNT = 10

export const Index = () => {

    // 入力用DOMノードへの参照を保持する
    const inputRef = useRef();
    const classes = useStyles();//Material-ui
    //const history = useHistory()
    const [list, setList] = useState([])
+     const [firstRecord, setFirstRecord] = useState()
+     const [lastRecord, setLastRecord] = useState()
+     const [currentfirstRecord, setCurrentFirstRecord] = useState()
+     const [currentlastRecord, setCurrentLastRecord] = useState()
+     const [hasPreviousPage, sethasPreviousPage] = useState()
+     const [hasNextPage, sethasNextPage] = useState()

+     useEffect(() => {
+         if (lastRecord && list) {
+             if (list.length > 0 ){
+                 sethasNextPage(true)
+                 list.forEach((item) => {
+                     //console.log("useEffect:list.item", item.docId)
+                     if ( item.docId === lastRecord.docId){
+                         console.log("useEffect:lastRecord _ari", lastRecord)
+                         sethasNextPage(false)
+                     }
+                 })
+                 //最後のレコードを取得する
+                 setCurrentLastRecord(list.slice(-1)[0]);
+             }
+         }
+         if (firstRecord && list) {
+             if (list.length > 0 ){
+                 //console.log("useEffect:list",list)
+                 //console.log("useEffect:firstRecord",firstRecord)
+                 sethasPreviousPage(true)
+                 list.forEach((item) => {
+                     //console.log("useEffect:list.item", item.docId)
+                     if ( item.docId === firstRecord.docId){
+                         console.log("useEffect:firstRecord _ari", firstRecord)
+                         sethasPreviousPage(false)
+                     }
+                 })
+             }
+             //最初のレコードを取得する
+             setCurrentFirstRecord(list[0]);
+         }
+     },[firstRecord,lastRecord,list]);


    useEffect(() => {
         async function fetchData() { // featchDataという関数を定義し、それにasyncをつける

            //最初のページを取得する
            const colRef = db.collection("members")
            .orderBy('createdAt', 'desc')
+             .limit(10);
-             .limit(LIMIT_COUNT);
            console.log(colRef)
            const snapshots = await colRef.get();
            var docs = snapshots.docs.map(function (doc) {
                return doc.data();
            });
            setList(docs)

        }

+         async function initialData() { // featchDataという関数を定義し、それにasyncをつける
+             //最後のレコードをセットする
+             await db.collection("members").orderBy('createdAt', 'desc').limitToLast(1)
+             .get()
+             .then(function(querySnapshot) {
+                 querySnapshot.forEach(function(doc) {
+                     setLastRecord(doc.data())
+                 });
+             })
+             .catch(function(error) {
+                 console.log("Error getting documents: ", error);
+             });
+             //最初のレコードをセットする
+             await db.collection("members").orderBy('createdAt', 'desc').limit(1)
+             .get()
+             .then(function(querySnapshot) {
+                 querySnapshot.forEach(function(doc) {
+                     setFirstRecord(doc.data())
+                 });
+             })
+             .catch(function(error) {
+                 console.log("Error getting documents: ", error);
+             });
+         }
+         initialData();
        fetchData();
    },[inputRef]);

+     async function handleNextPage () {  
+         console.log("handleNextPage")
+         //console.log("currentfirstRecord",currentfirstRecord)
+         //console.log("currentlastRecord",currentlastRecord)
+         //次のページを取得する
+         const colRef = db.collection("members")
+         .orderBy('createdAt', 'desc')
+         .limit(LIMIT_COUNT)
+         .startAfter(currentlastRecord.createdAt);
+         const snapshots = await colRef.get();
+         var docs = snapshots.docs.map(function (doc) {
+             return doc.data();
+         });
+         setList(docs)
+     }

+     async function handlePreviousPage () {  
+         console.log("handlePreviousPage")
+         //前のページを取得する
+         const colRef = db.collection("members")
+         .orderBy('createdAt')
+         .limit(LIMIT_COUNT)
+         .startAfter(currentfirstRecord.createdAt);
+         const snapshots = await colRef.get();
+         var docs = snapshots.docs.map(function (doc) {
+             return doc.data();
+         });
+         //sort処理
+         docs.sort(function(a,b){
+             if(a.createdAt>b.createdAt) return -1;
+             if(a.createdAt < b.createdAt) return 1;
+             return 0;
+         });
+         setList(docs)
+     }

    return (
        <>
            <Typography variant="h4" align="center" component="h1" gutterBottom>
                一覧表示
            </Typography>
            <TableContainer component={Paper}>
                <Table className={classes.table} aria-label="simple table">
                    <TableHead>
                        <TableRow>
                            <TableCell>ID</TableCell>
                            <TableCell>Email</TableCell>
                            <TableCell>ハンドル名</TableCell>
                            <TableCell>作成日</TableCell>
                            <TableCell>操作</TableCell>
                        </TableRow>
                    </TableHead>
                    <TableBody>
                    {
                    list.map((item, index) => {
                        return (
                            <TableRow key={item.docId}>
                                <TableCell component="th" scope="row">
                                    {item.docId}
                                </TableCell>
                                <TableCell >{item.email}</TableCell>
                                <TableCell >{item.displayName}</TableCell> 
                                <TableCell >
                                  { moment(item.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}
                                </TableCell> 
                                <TableCell ><Link to={`./edit/${item.docId}`}>編集</Link></TableCell> 
                            </TableRow>
                            )
                        })
                    }
                    </TableBody>
                </Table>
                </TableContainer>
+             <div variant="subtitle1" align="center" justify="center" component="h1" >
+                 {hasPreviousPage && <><Fab component="span" className={classes.button} onClick={handlePreviousPage} ><NavigateBefore className={classes.largeIcon} color={"primary"} /></Fab> </>}
+                 {!hasPreviousPage && <><Fab component="span" className={classes.button}><NavigateBefore className={classes.largeIcon} color={"disabled"}/></Fab> </>}
+                 {hasNextPage && <><Fab component="span" className={classes.button}  onClick={handleNextPage}   ><NavigateNext className={classes.largeIcon} color={"primary"} /></Fab></>}
+                 {!hasNextPage && <><Fab component="span" className={classes.button}><NavigateNext className={classes.largeIcon} color={"disabled"} /></Fab></>}
+             </div>
            <Typography className={classes.subtitle2} variant="subtitle2"><Link to="/screens/edit">新規作成</Link></Typography>
            <Typography className={classes.subtitle2} variant="subtitle2"><Link to="/">Homeに戻る</Link></Typography>

        </>

    );
}

プログラム解説1

初期読み込みの時に3つのデータを取得しています

  • 1ページのデータ
  • 最初のレコードを取得する
  • 最後のレコードを取得する

例えば

  • 1,2,3,4,5,6,7,8,9,10というデータ
  • 1ページに3レコード

上記の場合は

  • 1ページのデータ => 1,2,3のデータ
  • 最初のレコードを取得する => 1のデータ
  • 最後のレコードを取得する => 10のデータ

になります

    const [firstRecord, setFirstRecord] = useState()
    const [lastRecord, setLastRecord] = useState()

    useEffect(() => {
         async function fetchData() { // featchDataという関数を定義し、それにasyncをつける

            //最初のページを取得する
            const colRef = db.collection("members")
            .orderBy('createdAt', 'desc')
            .limit(LIMIT_COUNT);
            console.log(colRef)
            const snapshots = await colRef.get();
            var docs = snapshots.docs.map(function (doc) {
                return doc.data();
            });
            setList(docs)

        }

        async function initialData() { // featchDataという関数を定義し、それにasyncをつける
            //最後のレコードをセットする
            await db.collection("members").orderBy('createdAt', 'desc').limitToLast(1)
            .get()
            .then(function(querySnapshot) {
                querySnapshot.forEach(function(doc) {
                    setLastRecord(doc.data())
                });
            })
            .catch(function(error) {
                console.log("Error getting documents: ", error);
            });
            //最初のレコードをセットする
            await db.collection("members").orderBy('createdAt', 'desc').limit(1)
            .get()
            .then(function(querySnapshot) {
                querySnapshot.forEach(function(doc) {
                    setFirstRecord(doc.data())
                });
            })
            .catch(function(error) {
                console.log("Error getting documents: ", error);
            });
        }
        initialData();
        fetchData();
    },[inputRef]);

初期読み込みはいつもどおり「useEffect」の関数を使っています。

プログラム解説2

  • 次と前のページのリンクを表示するのかどうかを判定しています。
  • 現在表示しているページの最初のレコードと最後のレコードを取得しています。
  • この処理はデータ(firstRecord,lastRecord,list)をいれたあとに起動(hook)する形になっています。つまりページ制御してデータを読み込むごとに実行しています
const [currentfirstRecord, setCurrentFirstRecord] = useState()
const [currentlastRecord, setCurrentLastRecord] = useState()
const [hasPreviousPage, sethasPreviousPage] = useState()
const [hasNextPage, sethasNextPage] = useState()

useEffect(() => {
    if (lastRecord && list) {
        if (list.length > 0 ){
            sethasNextPage(true)
            list.forEach((item) => {
                //console.log("useEffect:list.item", item.docId)
                if ( item.docId === lastRecord.docId){
                    console.log("useEffect:lastRecord _ari", lastRecord)
                    sethasNextPage(false)
                }
            })
            //最後のレコードを取得する
            setCurrentLastRecord(list.slice(-1)[0]);
        }
    }

    if (firstRecord && list) {
        if (list.length > 0 ){
            //console.log("useEffect:list",list)
            //console.log("useEffect:firstRecord",firstRecord)
            sethasPreviousPage(true)
            list.forEach((item) => {
                //console.log("useEffect:list.item", item.docId)
                if ( item.docId === firstRecord.docId){
                    console.log("useEffect:firstRecord _ari", firstRecord)
                    sethasPreviousPage(false)
                }
            })
        }
        //最初のレコードを取得する
        setCurrentFirstRecord(list[0]);

    }
},[firstRecord,lastRecord,list]);

プログラム解説3

次のページを取得する処理になります。 startAfterというメソッドを使ってレコードを取得する範囲を設定している。設定する値は 現在、表示している最後のレコードになっています

async function handleNextPage () {  
    console.log("handleNextPage")
    //console.log("currentfirstRecord",currentfirstRecord)
    //console.log("currentlastRecord",currentlastRecord)
    //次のページを取得する
    const colRef = db.collection("members")
    .orderBy('createdAt', 'desc')
    .limit(LIMIT_COUNT)
    .startAfter(currentlastRecord.createdAt);

    const snapshots = await colRef.get();
    var docs = snapshots.docs.map(function (doc) {
        return doc.data();
    });
    setList(docs)
}

プログラム解説4

前のページを取得する処理になります。

async function handlePreviousPage () {  
    console.log("handlePreviousPage")
    //console.log("currentfirstRecord",currentfirstRecord)
    //console.log("currentlastRecord",currentlastRecord)
    //前のページを取得する
    const colRef = db.collection("members")
    .orderBy('createdAt')
    .limit(LIMIT_COUNT)
    .startAfter(currentfirstRecord.createdAt);
    const snapshots = await colRef.get();
    var docs = snapshots.docs.map(function (doc) {
        return doc.data();
    });
    //sort処理
    docs.sort(function(a,b){
        if(a.createdAt>b.createdAt) return -1;
        if(a.createdAt < b.createdAt) return 1;
        return 0;
    });
    setList(docs)
}
  • 他のデータ取得のプログラムと違うのはソート条件が違うという事です。

    次ページ、1ページ取得処理
     => .orderBy('createdAt', 'desc')
    前ページ取得処理
     => .orderBy('createdAt')

  • 取得したデータを逆ソートしています。

    //sort処理
    docs.sort(function(a,b){
        if(a.createdAt>b.createdAt) return -1;
        if(a.createdAt < b.createdAt) return 1;
        return 0;
    });
  • 例えば
    1,2,3,4,5,6,7,8,9,10のデータがある時に、 currentのデータの範囲が4,5,6だった。
    現在、表示している最初のレコードは4になる。
    4のレコードを基軸にデータを取得する。
    取得したデータは3,2,1になる。 そのデータを逆転ソートをすることで
    1,2,3になって正しく表示する事ができる

プログラム解説5

次前のボタンの所です。ボタンはfabを使っています。

<div variant="subtitle1" align="center" justify="center" component="h1" >
    {hasPreviousPage && <><Fab component="span" className={classes.button} onClick={handlePreviousPage} ><NavigateBefore className={classes.largeIcon} color={"primary"} /></Fab> </>}
    {!hasPreviousPage && <><Fab component="span" className={classes.button}><NavigateBefore className={classes.largeIcon} color={"disabled"}/></Fab> </>}
    {hasNextPage && <><Fab component="span" className={classes.button}  onClick={handleNextPage}   ><NavigateNext className={classes.largeIcon} color={"primary"} /></Fab></>}
    {!hasNextPage && <><Fab component="span" className={classes.button}><NavigateNext className={classes.largeIcon} color={"disabled"} /></Fab></>}
</div>    

次前のボタンの出し分けをしている以外は難しい所はありません

ハマった所

  • 初期で3回ほどデータを取りに行かないといけないのですが、callback関数をつないで作っていたのですが、データのセットまでにタイムラグがあってうまくいきませんでした。最終的にはすべてのデータをuseEffectで監視する事でうまくいきました。全部のデータが揃った時に動かすという処理ができてよかったです

感想

  • mysqlのなら楽勝なのにoffsetが使えないとかマジきつい
  • レコード数だけはとれるようにしてほしい。あわよくば現在のページかな。