├── .gitignore ├── tslint.json ├── .prettierrc ├── tsconfig.json ├── package.json ├── README.md └── src └── index.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib 3 | .idea -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"] 3 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./lib", 7 | "strict": true 8 | }, 9 | "include": ["src"], 10 | "exclude": ["node_modules", "**/__tests__/*"] 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-pagination-firestore", 3 | "version": "0.0.2", 4 | "description": "A non-cumulative pagination hook for use with Firestore", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "format": "prettier --write \"src/**/*.ts\"", 11 | "lint": "tslint -p tsconfig.json", 12 | "prepare": "npm run build", 13 | "prepublishOnly": "npm run lint", 14 | "preversion": "npm run lint", 15 | "version": "npm run format && git add -A src", 16 | "postversion": "git push && git push --tags" 17 | }, 18 | "files": [ 19 | "lib/**/*" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/premshree/use-pagination-firestore.git" 24 | }, 25 | "keywords": [ 26 | "firestore", 27 | "firebase", 28 | "pagination", 29 | "hooks" 30 | ], 31 | "author": "Premshree Pillai", 32 | "license": "ISC", 33 | "devDependencies": { 34 | "@types/react": "^16.9.36", 35 | "firebase": "^7.15.1", 36 | "prettier": "^2.0.5", 37 | "react": "^16.13.1", 38 | "tslint": "^6.1.2", 39 | "tslint-config-prettier": "^1.18.0", 40 | "typescript": "^3.9.5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # use-pagination-firestore 2 | 3 | A React Hook that makes it easy to paginate firestore collections. 4 | This hook is similar, but the not the same as 5 | [firestore-pagination-hook](https://github.com/bmcmahen/firestore-pagination-hook). This hook 6 | provides _non cumulative_ pagination and does _not_ maintain references to previous 7 | documents, so it might be suitable for large document sets. 8 | 9 | ## Install 10 | 11 | ``` 12 | npm install use-pagination-firestore 13 | ``` 14 | 15 | ## Example Use 16 | 17 | This is an example of a "recently added perfumes" section built using [Material UI](https://material-ui.com/) 18 | and [Firestore](https://firebase.google.com/docs/firestore/). You can see it live on the Petrichor homepage [here](https://petrichor.se/), or 19 | [here](https://imgur.com/a/nUrgzaO) is a screencast. 20 | 21 | ```jsx 22 | import React from 'react'; 23 | import Grid from "@material-ui/core/Grid"; 24 | import PerfumeCard from "./search/PerfumeCard"; 25 | import {usePagination} from "use-pagination-firestore"; 26 | import Loading from "./Loading"; 27 | import { 28 | NavigateNext as NavgateNextIcon, 29 | NavigateBefore as NavigateBeforeIcon 30 | } from '@material-ui/icons'; 31 | import {IconButton} from "@material-ui/core"; 32 | import firebase from "firebase/app"; 33 | 34 | const RecentPerfumes = () => { 35 | const { 36 | items, 37 | isLoading, 38 | isStart, 39 | isEnd, 40 | getPrev, 41 | getNext, 42 | } = usePagination( 43 | firebase 44 | .firestore() 45 | .collection("/perfumes") 46 | .orderBy("updated", "desc"), 47 | { 48 | limit: 10 49 | } 50 | ); 51 | 52 | if (isLoading) { 53 | return ; 54 | } 55 | 56 | return ( 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | {items.map((perfume, idx) => { 71 | return ( 72 | 73 | 74 | 75 | ); 76 | })} 77 | 78 | ); 79 | } 80 | 81 | export default RecentPerfumes; 82 | ``` 83 | 84 | ## Caveats 85 | 86 | Paginating Firestore documents relies on [query cursors](https://firebase.google.com/docs/firestore/query-data/query-cursors). It's not easy to know 87 | ahead of time how many documents exist in a collection. Consequently, if your `document_count % page_size` is `0` you will notice that your last page 88 | is empty – this is because this hook doesn't (currently) look ahead to know if there are any more documents. -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from 'firebase/app'; 2 | import { MutableRefObject, useEffect, useReducer, useRef } from 'react'; 3 | 4 | export interface PaginationOptions { 5 | limit?: number; 6 | } 7 | 8 | interface State { 9 | query: firestore.Query | undefined; 10 | queryRef: undefined | MutableRefObject; 11 | lastQuery: firestore.Query | undefined; 12 | firstDocRef: undefined | MutableRefObject; 13 | docs: firestore.QueryDocumentSnapshot[]; 14 | firstDoc: firestore.QueryDocumentSnapshot | undefined; 15 | lastDoc: firestore.QueryDocumentSnapshot | undefined; 16 | prevQuery: firestore.Query | undefined; 17 | nextQuery: firestore.Query | undefined; 18 | items: T[]; 19 | isLoading: boolean; 20 | isStart: boolean; 21 | isEnd: boolean; 22 | limit: number; 23 | } 24 | 25 | type ActionBase = V extends void ? { type: K } : { type: K } & V; 26 | 27 | type Action = 28 | | ActionBase< 29 | 'SET-QUERY', 30 | { 31 | payload: { 32 | query: firestore.Query; 33 | queryRef: MutableRefObject; 34 | firstDocRef: MutableRefObject; 35 | limit: number; 36 | }; 37 | } 38 | > 39 | | ActionBase< 40 | 'LOAD', 41 | { 42 | payload: { 43 | value: firestore.QuerySnapshot; 44 | query: firestore.Query; 45 | }; 46 | } 47 | > 48 | | ActionBase<'PREV'> 49 | | ActionBase<'NEXT'>; 50 | 51 | const defaultGuard = (state: S, a: never) => state; 52 | 53 | const getReducer = () => (state: State, action: Action): State => { 54 | switch (action.type) { 55 | case 'SET-QUERY': { 56 | const { query, queryRef, firstDocRef, limit } = action.payload; 57 | return { 58 | ...state, 59 | query: query.limit(limit), 60 | queryRef, 61 | firstDocRef, 62 | limit, 63 | isLoading: true, 64 | }; 65 | } 66 | 67 | case 'LOAD': { 68 | const { value } = action.payload; 69 | const docs = value.docs; 70 | 71 | const items = docs.map((doc) => { 72 | return doc.data() as T; 73 | }); 74 | 75 | const firstDoc = docs[0]; 76 | const lastDoc = docs[docs.length - 1]; 77 | const queryFromRef = state.queryRef ? state.queryRef.current : undefined; 78 | const prevQuery = 79 | queryFromRef && firstDoc ? queryFromRef.endBefore(firstDoc).limitToLast(state.limit) : state.lastQuery; 80 | const nextQuery = queryFromRef && lastDoc ? queryFromRef.startAfter(lastDoc).limit(state.limit) : state.nextQuery; 81 | 82 | const firstDocRef = state.firstDocRef; 83 | if (firstDocRef && firstDocRef.current === undefined) { 84 | firstDocRef.current = firstDoc; 85 | } 86 | 87 | return { 88 | ...state, 89 | lastQuery: items.length > 0 ? state.query : undefined, 90 | isLoading: false, 91 | firstDoc, 92 | firstDocRef, 93 | lastDoc, 94 | prevQuery, 95 | nextQuery, 96 | items, 97 | isStart: (firstDoc && firstDocRef?.current?.isEqual(firstDoc)) || false, 98 | isEnd: items.length < state.limit, 99 | }; 100 | } 101 | 102 | case 'NEXT': { 103 | return { 104 | ...state, 105 | isLoading: true, 106 | query: state.nextQuery, 107 | }; 108 | } 109 | 110 | case 'PREV': { 111 | return { 112 | ...state, 113 | isLoading: true, 114 | query: state.prevQuery, 115 | }; 116 | } 117 | 118 | default: { 119 | return defaultGuard(state, action); 120 | } 121 | } 122 | }; 123 | 124 | const initialState = { 125 | query: undefined, 126 | queryRef: undefined, 127 | lastQuery: undefined, 128 | firstDocRef: undefined, 129 | docs: [], 130 | firstDoc: undefined, 131 | lastDoc: undefined, 132 | prevQuery: undefined, 133 | nextQuery: undefined, 134 | items: [], 135 | isLoading: true, 136 | isStart: true, 137 | isEnd: false, 138 | limit: 10, 139 | }; 140 | 141 | const usePagination = (firestoreQuery: firestore.Query, options: PaginationOptions) => { 142 | const [state, dispatch] = useReducer(getReducer(), initialState); 143 | const queryRef = useRef(undefined); 144 | const firstDocRef = useRef(undefined); 145 | 146 | const { limit = 10 } = options; 147 | 148 | useEffect(() => { 149 | if (firestoreQuery !== undefined) { 150 | if (queryRef.current === undefined) { 151 | queryRef.current = firestoreQuery; 152 | } 153 | dispatch({ 154 | type: 'SET-QUERY', 155 | payload: { 156 | query: firestoreQuery, 157 | queryRef, 158 | firstDocRef, 159 | limit, 160 | }, 161 | }); 162 | } 163 | }, []); 164 | 165 | useEffect(() => { 166 | if (state.query !== undefined) { 167 | const unsubscribe = state.query.onSnapshot((snap) => { 168 | dispatch({ 169 | type: 'LOAD', 170 | payload: { value: snap, query: state.query as firestore.Query }, 171 | }); 172 | }); 173 | 174 | return () => unsubscribe(); 175 | } 176 | }, [state.query]); 177 | 178 | return { 179 | items: state.items, 180 | isLoading: state.isLoading, 181 | isStart: state.isStart, 182 | isEnd: state.isEnd, 183 | getPrev: () => dispatch({ type: 'PREV' }), 184 | getNext: () => dispatch({ type: 'NEXT' }), 185 | }; 186 | }; 187 | 188 | export { usePagination }; 189 | --------------------------------------------------------------------------------