├── .eslintrc.json ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc ├── .prettierrc.json ├── README.md ├── commitlint.config.js ├── examples ├── useCollectionExample.ts └── useInfiniteCollectionExample.ts ├── package.json ├── src ├── Cache.ts ├── Provider.tsx ├── index.ts ├── types.ts ├── useCollection.ts ├── useDocument.ts ├── useHelpers.ts ├── useInfiniteCollection.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-native-wcandillon", 3 | "rules": { 4 | "max-len": 0, 5 | "@typescript-eslint/no-explicit-any": 0, 6 | "import/extensions": 0, 7 | "@typescript-eslint/camelcase": "off", 8 | "@typescript-eslint/explicit-function-return-type": "off", 9 | "@typescript-eslint/no-use-before-define": "off", 10 | "@typescript-eslint/ban-ts-ignore": "off", 11 | "react/jsx-filename-extension": "off", 12 | "@typescript-eslint/consistent-type-imports": "off", 13 | "no-undef": "off", 14 | "no-shadow": "off", 15 | "@typescript-eslint/no-shadow": ["warn", { "ignoreTypeValueShadow": true }] 16 | } 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # VSCode 6 | .vscode/ 7 | jsconfig.json 8 | 9 | # node.js 10 | # 11 | node_modules/ 12 | npm-debug.log 13 | yarn-debug.log 14 | yarn-error.log 15 | 16 | # generated by bob 17 | lib/ 18 | example/ 19 | example/**/* -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | yarn lint-staged -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.{ts,tsx}": [ 3 | "eslint --max-warnings=0 --fix", 4 | "prettier --write" 5 | ] 6 | } -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "trailingComma": "all", 4 | "tabWidth": 4, 5 | "semi": true, 6 | "singleQuote": false, 7 | "bracketSpacing": false, 8 | "jsxBracketSameLine": true 9 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Query + Firestore 2 | 3 | ```js 4 | const { data } = useDocument('users/fernando') 5 | ``` 6 | 7 | **It's that easy.** 8 | 9 | 🔥 This library provides the hooks you need for querying Firestore, that you can actually use in production, on every screen. 10 | 11 | ⚡️ It aims to be **the fastest way to use Firestore in a React app,** both from a developer experience and app performance perspective. 12 | 13 | 🍕 This library is built on top [react-query](https://react-query.tanstack.com/), meaning you get all of its awesome benefits out-of-the-box. 14 | 15 | You can now fetch, add, and mutate Firestore data with zero boilerplate. 16 | 17 | ## Credit 18 | 19 | I'd like to thank [(@fernandotherojo)](https://twitter.com/fernandotherojo), this repo is a fork from his swr-firestore repo. all i did was migrating the core logic from swr to react query 20 | 21 | make sure to check his repo: https://github.com/nandorojo/swr-firestore 22 | 23 | ## Features 24 | 25 | - Shared state / cache between collection and document queries [(instead of Redux??)](#shared-global-state-between-documents-and-collections) 26 | - Works with both **React** and **React Native**. 27 | - Blazing fast 28 | - Query collection groups 29 | - `set`, `update`, and `add` update your global cache, instantly 30 | - TypeScript-ready [(see docs)](#typescript-support) 31 | - Realtime subscriptions [(example)](#simple-examples) 32 | - Prevent memory leaks from Firestore subscriptions 33 | - No more parsing `document.data()` from Firestore requests 34 | 35 | ...along with the features touted by [react-query](https://react-query.tanstack.com/) library: 36 | 37 | - Transport and protocol agnostic data fetching 38 | - Fast page navigation 39 | - Revalidation on focus 40 | - Interval polling 41 | - Request deduplication 42 | - Local mutation 43 | - Pagination 44 | - TypeScript ready 45 | - SSR support 46 | - Suspense mode 47 | - Minimal API 48 | 49 | ## ⭐️ 50 | 51 | If you like this library, give it star 52 | 53 | ## Installation 54 | 55 | ```sh 56 | yarn add react-query-firestore 57 | 58 | # or 59 | npm install react-query-firestore 60 | ``` 61 | 62 | Install firebase: 63 | 64 | ```sh 65 | # if you're using expo: 66 | expo install firebase 67 | 68 | # if you aren't using expo: 69 | yarn add firebase 70 | # or 71 | npm i firebase 72 | ``` 73 | 74 | ## Set up 75 | 76 | In the root of your app, **create an instance of firestore** and [(react query config object)](https://react-query.tanstack.com/reference/useQuery) and pass it to the **ReactQueryFirestoreProvider**. 77 | 78 | If you're using `next.js`, this goes in your `pages/_app.js` file. 79 | 80 | `App.js` 81 | 82 | ```jsx 83 | import React from 'react' 84 | import * as firebase from 'firebase/app' 85 | import 'firebase/firestore' 86 | import { ReactQueryFirestoreProvider } from 'react-query-firestore' 87 | 88 | const reactQueryConfig = { 89 | queries: { 90 | retry: false 91 | } 92 | } 93 | 94 | export default function App() { 95 | return ( 96 | 97 | 98 | 99 | ) 100 | } 101 | ``` 102 | 103 | ## Basic Usage 104 | 105 | _Assuming you've already completed the setup..._ 106 | 107 | ### Subscribe to a document 108 | 109 | ```js 110 | import React from 'react' 111 | import { useDocument } from 'react-query-firestore' 112 | import { Text } from 'react-native' 113 | 114 | export default function User() { 115 | const user = { id: 'Fernando' } 116 | const { data, update, error } = useDocument(`users/${user.id}`) 117 | 118 | if (error) return Error! 119 | if (!data) return Loading... 120 | 121 | return Name: {data.name} 122 | } 123 | ``` 124 | 125 | ### Get a collection 126 | 127 | ```js 128 | import React from 'react' 129 | import { useCollection } from 'react-query-firestore' 130 | import { Text } from 'react-native' 131 | 132 | export default function UserList() { 133 | const { data, add, error } = useCollection(`users`) 134 | 135 | if (error) return Error! 136 | if (!data) return Loading... 137 | 138 | return data.map(user => {user.name}) 139 | } 140 | ``` 141 | 142 | `useDocument` accepts a document `path` as its first argument here. `useCollection` works similarly. 143 | 144 | ## Simple examples 145 | 146 | ### Query a users collection: 147 | 148 | ```typescript 149 | const { data } = useCollection('users') 150 | ``` 151 | 152 | ### Make a complex collection query: 153 | 154 | ```typescript 155 | const { data } = useCollection('users', {}, { 156 | where: ['name', '==', 'fernando'], 157 | limit: 10, 158 | orderBy: ['age', 'desc'], 159 | }) 160 | ``` 161 | 162 | ### Pass options from react-query to your document query: 163 | 164 | ```typescript 165 | // pass react-query options 166 | const { data } = useDocument('albums/nothing-was-the-same', { 167 | retry: false, 168 | onSuccess: console.log, 169 | }) 170 | ``` 171 | 172 | ### Pass options from react-query to your collection query: 173 | 174 | ```typescript 175 | // pass react-query options 176 | const { data } = useCollection( 177 | 'albums', 178 | , 179 | { 180 | retry: false, 181 | onSuccess: console.log, 182 | } 183 | { 184 | // you can pass multiple where conditions if you want 185 | where: [ 186 | ['artist', '==', 'Drake'], 187 | ['year', '==', '2020'], 188 | ], 189 | } 190 | ) 191 | ``` 192 | 193 | ### Add data to your collection: 194 | 195 | ```typescript 196 | const { data, add } = useCollection('albums', { 197 | where: ['artist', '==', 'Drake'], 198 | }) 199 | 200 | const onPress = () => { 201 | // calling this will automatically update your global cache & Firestore 202 | add({ 203 | title: 'Dark Lane Demo Tapes', 204 | artist: 'Drake', 205 | year: '2020', 206 | }) 207 | } 208 | ``` 209 | 210 | ### Set document data: 211 | 212 | ```typescript 213 | const { data, set, update } = useDocument('albums/dark-lane-demo-tapes') 214 | 215 | const onReleaseAlbum = () => { 216 | // calling this will automatically update your global cache & Firestore 217 | set( 218 | { 219 | released: true, 220 | }, 221 | { merge: true } 222 | ) 223 | 224 | // or you could call this: 225 | update({ 226 | released: true, 227 | }) 228 | } 229 | ``` 230 | 231 | ### Use dynamic fields in a request: 232 | 233 | If you pass `undefined` as the document key, the request won't send. 234 | 235 | Once the key is set to a string, the request will send. 236 | 237 | **Get list of users who have you in their friends list** 238 | 239 | ```typescript 240 | import { useDoormanUser } from 'react-doorman' 241 | 242 | const { uid } = useDoormanUser() 243 | const { data } = useDocument(uid ? 'users/'+uid : undefined, { 244 | where: ['friends', 'array-contains', uid], 245 | }) 246 | ``` 247 | 248 | **Get your favorite song** 249 | 250 | ```typescript 251 | const me = { id: 'fernando' } 252 | 253 | const { data: user } = useDocument<{ favoriteSong: string }>(`users/${me.id}`) 254 | 255 | // only send the request once the user.favoriteSong exists! 256 | const { data: song } = useDocument( 257 | user?.favoriteSong ? `songs/${user.favoriteSong}` : undefined 258 | ) 259 | ``` 260 | 261 | ## Query Documents 262 | 263 | You'll rely on `useDocument` to query documents. 264 | 265 | ```js 266 | import React from 'react' 267 | import { useDocument } from 'react-query-firestore' 268 | 269 | const user = { id: 'Fernando' } 270 | export default () => { 271 | const { data, error } = useDocument(`users/${user.id}`) 272 | } 273 | ``` 274 | 275 | # Features 276 | 277 | ## TypeScript Support 278 | 279 | Create a model for your `typescript` types, and pass it as a generic to `useDocument` or `useCollection`. 280 | 281 | ### useDocument 282 | 283 | The `data` item will include your TypeScript model (or `undefined`), and will also include an `id` string, an `exists` boolean, and `hasPendingWrites` boolean. 284 | 285 | ```typescript 286 | type User = { 287 | name: string 288 | } 289 | 290 | const { data } = useDocument('users/fernando') 291 | 292 | if (data) { 293 | const { 294 | id, // string 295 | name, // string 296 | exists, // boolean 297 | hasPendingWrites, // boolean 298 | } = data 299 | } 300 | 301 | const id = data?.id // string | undefined 302 | const name = data?.name // string | undefined 303 | const exists = data?.exists // boolean | undefined 304 | const hasPendingWrites = data?.hasPendingWrites // boolean | undefined 305 | ``` 306 | 307 | ### useCollection 308 | 309 | The `data` item will include your TypeScript model (or `undefined`), and will also include an `id` string. 310 | 311 | ```typescript 312 | type User = { 313 | name: string 314 | } 315 | 316 | const { data } = useCollection('users') 317 | 318 | if (data) { 319 | data.forEach(({ id, name }) => { 320 | // ... 321 | }) 322 | } 323 | ``` 324 | 325 | ## Shared global state between documents and collections 326 | 327 | A great feature of this library is shared data between documents and collections. Until now, this could only be achieved with something like a verbose Redux set up. 328 | 329 | So, what does this mean exactly? 330 | 331 | Simply put, any documents pulled from a Firestore request will update the global cache. 332 | 333 | **To make it clear, let's look at an example.** 334 | 335 | Imagine you query a `user` document from Firestore: 336 | 337 | ```js 338 | const { data } = useDocument('users/fernando') 339 | ``` 340 | 341 | And pretend that this document's `data` returns the following: 342 | 343 | ```json 344 | { "id": "fernando", "isHungry": false } 345 | ``` 346 | 347 | _Remember that `isHungry` is `false` here ^_ 348 | 349 | Now, let's say you query the `users` collection anywhere else in your app: 350 | 351 | ```js 352 | const { data } = useCollection('users') 353 | ``` 354 | 355 | And pretend that this collection's `data` returns the following: 356 | 357 | ```json 358 | [ 359 | { "id": "fernando", "isHungry": true }, 360 | { 361 | //... 362 | } 363 | ] 364 | ``` 365 | 366 | Whoa, `isHungry` is now true. But what happens to the original document query? Will we have stale data? 367 | 368 | **Answer:** It will automatically re-render with the new data! 369 | 370 | `swr-firestore` uses document `id` fields to sync any collection queries with existing document queries across your app. 371 | 372 | That means that **if you somehow fetch the same document twice, the latest version will update everywhere.** 373 | 374 | ## License 375 | 376 | MIT 377 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /examples/useCollectionExample.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useMemo, useState} from "react"; 2 | import {useCollection} from "react-query-firestore"; 3 | 4 | interface Post { 5 | id: string; 6 | text: string; 7 | createdAt: any; 8 | } 9 | 10 | export const usePosts = () => { 11 | const [isMounted, setIsMounted] = useState(false); 12 | 13 | const {data, status, add, fetchNextPage, isFetchingNextPage, hasNextPage} = 14 | useCollection( 15 | isMounted ? "posts" : undefined, 16 | { 17 | keepPreviousData: true, 18 | }, 19 | { 20 | ignoreFirestoreDocumentSnapshotField: false, 21 | orderBy: ["createdAt", "desc"], 22 | limit: 10, 23 | }, 24 | ); 25 | 26 | useEffect(() => { 27 | setIsMounted(true); 28 | }, []); 29 | 30 | const posts = useMemo(() => (data ? data : []), [data]); 31 | 32 | return { 33 | posts, 34 | status, 35 | addPost: add, 36 | isLoading: status === "loading", 37 | paginate: fetchNextPage, 38 | hasMore: hasNextPage, 39 | isFetchingMore: isFetchingNextPage, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /examples/useInfiniteCollectionExample.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useMemo, useState} from "react"; 2 | import {useInfiniteCollection} from "react-query-firestore"; 3 | 4 | interface Post { 5 | id: string; 6 | text: string; 7 | createdAt: any; 8 | } 9 | 10 | export const useInfinitePosts = () => { 11 | const [isMounted, setIsMounted] = useState(false); 12 | 13 | const {data, status, add, fetchNextPage, hasNextPage, isFetchingNextPage} = 14 | useInfiniteCollection( 15 | isMounted ? "posts" : undefined, 16 | {}, 17 | { 18 | orderBy: ["createdAt", "desc"], 19 | limit: 10, 20 | }, 21 | ); 22 | 23 | useEffect(() => { 24 | setIsMounted(true); 25 | }, []); 26 | 27 | const posts = useMemo( 28 | () => 29 | data 30 | ? data.map(({text, id, createdAt, __snapshot}) => { 31 | return { 32 | text, 33 | id, 34 | createdAt: createdAt.toDate?.(), 35 | __snapshot, 36 | }; 37 | }) 38 | : [], 39 | [data], 40 | ); 41 | 42 | return { 43 | posts, 44 | status, 45 | addPost: add, 46 | isLoading: status === "loading", 47 | paginate: fetchNextPage, 48 | hasMore: hasNextPage, 49 | isFetchingMore: isFetchingNextPage, 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-query-firestore", 3 | "version": "0.3.1", 4 | "description": "React Query for Firestore, that you can actually use in production, on every screen.", 5 | "main": "lib/commonjs/index.js", 6 | "module": "lib/module/index.js", 7 | "types": "lib/typescript/index.d.ts", 8 | "react-native": "src/index.ts", 9 | "files": [ 10 | "src", 11 | "lib" 12 | ], 13 | "scripts": { 14 | "lint": "eslint --ext .ts,.tsx .", 15 | "format": "prettier --write '{.,src/**}/*.{ts,tsx}'", 16 | "prepare": "npx husky install && bob build", 17 | "release": "release-it" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "react-native", 22 | "ios", 23 | "android", 24 | "web", 25 | "react query", 26 | "firestore", 27 | "firebase" 28 | ], 29 | "repository": "https://github.com/aminerol/react-query-firestore", 30 | "author": "Amine Bl (https://github.com/aminerol)", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/aminerol/react-query-firestore/issues" 34 | }, 35 | "homepage": "https://github.com/aminerol/react-query-firestore#readme", 36 | "devDependencies": { 37 | "@commitlint/config-conventional": "^13.1.0", 38 | "@firebase/firestore-types": "^2.5.0", 39 | "@release-it/conventional-changelog": "^3.3.0", 40 | "@types/react": "^16.9.19", 41 | "commitlint": "^13.1.0", 42 | "eslint": "^7.32.0", 43 | "eslint-config-prettier": "^8.3.0", 44 | "eslint-config-react-native-wcandillon": "^3.6.3", 45 | "eslint-plugin-prettier": "^4.0.0", 46 | "lint-staged": "^11.1.2", 47 | "prettier": "^2.3.2", 48 | "react-native-builder-bob": "^0.18.1", 49 | "release-it": "^14.11.5", 50 | "typescript": "^4.4.2" 51 | }, 52 | "peerDependencies": { 53 | "react": "*" 54 | }, 55 | "eslintIgnore": [ 56 | "node_modules/", 57 | "lib/" 58 | ], 59 | "release-it": { 60 | "git": { 61 | "commitMessage": "chore: release ${version}", 62 | "tagName": "v${version}" 63 | }, 64 | "npm": { 65 | "publish": true 66 | }, 67 | "github": { 68 | "release": true 69 | }, 70 | "plugins": { 71 | "@release-it/conventional-changelog": { 72 | "preset": "angular" 73 | } 74 | } 75 | }, 76 | "publishConfig": { 77 | "registry": "https://registry.npmjs.org/" 78 | }, 79 | "dependencies": { 80 | "fast-safe-stringify": "^2.1.1", 81 | "react-query": "^3.31.0" 82 | }, 83 | "react-native-builder-bob": { 84 | "source": "src", 85 | "output": "lib", 86 | "targets": [ 87 | "commonjs", 88 | "module", 89 | "typescript" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Cache.ts: -------------------------------------------------------------------------------- 1 | import {empty, Collections} from "./types"; 2 | 3 | /** 4 | * Collection cache 5 | * 6 | * This helps us keep track of which collections have been created. 7 | * 8 | * Whenever we edit a document, we then check the collection cache to see which collections we should also update. 9 | */ 10 | class CollectionCache { 11 | private collections: Collections; 12 | constructor() { 13 | this.collections = {}; 14 | } 15 | 16 | getKeysFromCollectionPath(path: string) { 17 | const isCollection = 18 | path.trim().split("/").filter(Boolean).length % 2 !== 0; 19 | if (!isCollection) { 20 | console.error( 21 | `[react-query-keys-from-collection-path] error: Passed a path that was not a collection to useCollection: ${path}.`, 22 | ); 23 | } 24 | return ( 25 | this.collections[path] 26 | ?.map(({key}) => 27 | // if the queryString is undefined, take it out of the array 28 | key.filter((keyItem) => typeof keyItem === "string"), 29 | ) 30 | .filter(Boolean) ?? empty.array 31 | ); 32 | } 33 | addCollectionToCache(path: string, queryString?: string) { 34 | const collectionAlreadyExistsInCache = this.collections[path]?.some( 35 | ({key}) => key[0] === path && key[1] === queryString, 36 | ); 37 | if (!collectionAlreadyExistsInCache) { 38 | this.collections = { 39 | ...this.collections, 40 | [path]: [ 41 | ...(this.collections[path] ?? empty.array), 42 | { 43 | key: [path, queryString], 44 | }, 45 | ], 46 | }; 47 | } 48 | return this.collections; 49 | } 50 | } 51 | 52 | export const collectionCache = new CollectionCache(); 53 | -------------------------------------------------------------------------------- /src/Provider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {QueryClient, QueryClientProvider, DefaultOptions} from "react-query"; 3 | import {persistQueryClient} from "react-query/persistQueryClient-experimental"; 4 | import {createAsyncStoragePersistor} from "react-query/createAsyncStoragePersistor-experimental"; 5 | import {FirebaseFirestore} from "@firebase/firestore-types"; 6 | import stringify from "fast-safe-stringify"; 7 | 8 | import {Storage} from "./types"; 9 | 10 | function createCtx() { 11 | const ctx = React.createContext(undefined); 12 | function useCtx() { 13 | const c = React.useContext(ctx); 14 | if (c === undefined) 15 | throw new Error("useCtx must be inside a Provider with a value"); 16 | return c; 17 | } 18 | return [useCtx, ctx.Provider] as const; // 'as const' makes TypeScript infer a tuple 19 | } 20 | 21 | interface FirestoreContextProps { 22 | firestore: FirebaseFirestore; 23 | } 24 | const [useFirestore, FirestoreProvider] = createCtx(); 25 | 26 | interface ProviderProps { 27 | reactQueryConfig?: DefaultOptions; 28 | firestore: FirebaseFirestore; 29 | } 30 | 31 | const queryClient = new QueryClient(); 32 | const enablePersistence = (storage: Storage) => { 33 | const asyncStoragePersistor = createAsyncStoragePersistor({ 34 | storage, 35 | serialize: stringify, 36 | }); 37 | 38 | persistQueryClient({ 39 | queryClient, 40 | persistor: asyncStoragePersistor, 41 | }); 42 | }; 43 | 44 | const ReactQueryFirestoreProvider = ({ 45 | children, 46 | reactQueryConfig, 47 | firestore, 48 | }: React.PropsWithChildren) => { 49 | queryClient.setDefaultOptions(reactQueryConfig || {}); 50 | return ( 51 | 52 | 53 | {children} 54 | 55 | 56 | ); 57 | }; 58 | 59 | export {useFirestore, ReactQueryFirestoreProvider, enablePersistence}; 60 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type {Document, Collections} from "./types"; 2 | export {ReactQueryFirestoreProvider, enablePersistence} from "./Provider"; 3 | export * from "./useCollection"; 4 | export * from "./useInfiniteCollection"; 5 | export * from "./useDocument"; 6 | export {useHelpers} from "./useHelpers"; 7 | export {useQueryClient, useMutation, useQuery} from "react-query"; 8 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {UseQueryOptions, UseInfiniteQueryOptions} from "react-query"; 2 | import { 3 | FieldPath, 4 | WhereFilterOp, 5 | QueryDocumentSnapshot, 6 | } from "@firebase/firestore-types"; 7 | 8 | export const empty = { 9 | object: {}, 10 | array: [], 11 | // eslint-disable-next-line @typescript-eslint/no-empty-function 12 | function: () => {}, 13 | }; 14 | 15 | export type AllowType = { 16 | [K in keyof O]: O[K] | Allowed; 17 | }; 18 | 19 | export type Document = T & { 20 | id: string; 21 | exists?: boolean; 22 | hasPendingWrites?: boolean; 23 | __snapshot?: QueryDocumentSnapshot; 24 | }; 25 | 26 | export type Options = UseQueryOptions; 27 | 28 | export type InfiniteOptions = UseInfiniteQueryOptions< 29 | Doc, 30 | Error, 31 | TData 32 | >; 33 | 34 | export type ListenerReturnType = { 35 | initialData: Doc; 36 | unsubscribe: () => void; 37 | }; 38 | 39 | // Collection Types 40 | 41 | export type Collections = { 42 | [path: string]: { 43 | key: [string, string | undefined]; // [path, queryString] 44 | }[]; 45 | }; 46 | 47 | type KeyHack = string & Record; // hack to also allow strings 48 | 49 | // here we get the "key" from our data, to add intellisense for any "orderBy" in the queries and such. 50 | export type OrderByArray = [ 51 | Key | FieldPath | KeyHack, 52 | "asc" | "desc", 53 | ]; 54 | export type OrderByItem = 55 | | OrderByArray 56 | | Key 57 | | KeyHack; 58 | export type OrderByType = 59 | | OrderByItem 60 | | OrderByArray[]; 61 | 62 | export type WhereItem = [ 63 | Key | FieldPath | KeyHack, 64 | WhereFilterOp, 65 | unknown, 66 | ]; 67 | export type WhereArray = WhereItem[]; 68 | export type WhereType = 69 | | WhereItem 70 | | WhereArray; 71 | 72 | export type CollectionQueryType = { 73 | limit?: number; 74 | orderBy?: OrderByType; 75 | where?: WhereType; 76 | isCollectionGroup?: boolean; 77 | 78 | /** 79 | * For now, this can only be a number, since it has to be JSON serializable. 80 | * 81 | * **TODO** allow DocumentSnapshot here too. This will probably be used with a useStaticCollection hook in the future. 82 | */ 83 | startAt?: number; 84 | /** 85 | * For now, this can only be a number, since it has to be JSON serializable. 86 | * 87 | * **TODO** allow DocumentSnapshot here too. This will probably be used with a useStaticCollection hook in the future. 88 | */ 89 | endAt?: number; 90 | /** 91 | * For now, this can only be a number, since it has to be JSON serializable. 92 | * 93 | * **TODO** allow DocumentSnapshot here too. This will probably be used with a useStaticCollection hook in the future. 94 | */ 95 | startAfter?: number; 96 | /** 97 | * For now, this can only be a number, since it has to be JSON serializable. 98 | * 99 | * **TODO** allow DocumentSnapshot here too. This will probably be used with a useStaticCollection hook in the future. 100 | */ 101 | endBefore?: number; 102 | 103 | // THESE ARE NOT JSON SERIALIZABLE 104 | // startAt?: number | DocumentSnapshot 105 | // endAt?: number | DocumentSnapshot 106 | // startAfter?: number | DocumentSnapshot 107 | // endBefore?: number | DocumentSnapshot 108 | }; 109 | 110 | export type Optional = Pick, K> & Omit; 111 | 112 | export interface Storage { 113 | getItem: (key: string) => Promise; 114 | setItem: (key: string, value: string) => Promise; 115 | removeItem: (key: string) => Promise; 116 | } 117 | 118 | export interface FirebaseHelpersOptions { 119 | /** 120 | * If true, the local cache won't be updated. Default `false`. 121 | */ 122 | ignoreLocalMutation?: boolean; 123 | identityState?: string; 124 | identityField?: string; 125 | } 126 | -------------------------------------------------------------------------------- /src/useCollection.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useMemo, useRef, useState} from "react"; 2 | import {QueryClient, useMutation, useQuery, useQueryClient} from "react-query"; 3 | import {FieldPath, FirebaseFirestore, Query} from "@firebase/firestore-types"; 4 | 5 | import {useFirestore} from "./Provider"; 6 | import {collectionCache} from "./Cache"; 7 | import { 8 | CollectionQueryType, 9 | Document, 10 | empty, 11 | Options, 12 | OrderByArray, 13 | OrderByType, 14 | WhereArray, 15 | WhereType, 16 | Optional, 17 | } from "./types"; 18 | import {parseDates} from "./utils"; 19 | 20 | const createFirestoreRef = ( 21 | firestore: FirebaseFirestore, 22 | path: string, 23 | { 24 | where, 25 | orderBy, 26 | limit, 27 | startAt, 28 | endAt, 29 | startAfter, 30 | endBefore, 31 | isCollectionGroup, 32 | }: CollectionQueryType, 33 | ) => { 34 | let ref: Query = firestore.collection(path); 35 | 36 | if (isCollectionGroup) { 37 | ref = firestore.collectionGroup(path); 38 | } 39 | 40 | if (where) { 41 | function multipleConditions(w: WhereType): w is WhereArray { 42 | return !!(w as WhereArray) && Array.isArray(w[0]); 43 | } 44 | if (multipleConditions(where)) { 45 | where.forEach((w) => { 46 | ref = ref.where(w[0] as string | FieldPath, w[1], w[2]); 47 | }); 48 | } else if ( 49 | typeof where[0] === "string" && 50 | typeof where[1] === "string" 51 | ) { 52 | ref = ref.where(where[0], where[1], where[2]); 53 | } 54 | } 55 | 56 | if (orderBy) { 57 | if (typeof orderBy === "string") { 58 | ref = ref.orderBy(orderBy); 59 | } else if (Array.isArray(orderBy)) { 60 | function multipleOrderBy(o: OrderByType): o is OrderByArray[] { 61 | return Array.isArray((o as OrderByArray[])[0]); 62 | } 63 | if (multipleOrderBy(orderBy)) { 64 | orderBy.forEach(([order, direction]) => { 65 | ref = ref.orderBy(order as string | FieldPath, direction); 66 | }); 67 | } else { 68 | const [order, direction] = orderBy; 69 | ref = ref.orderBy(order as string | FieldPath, direction); 70 | } 71 | } 72 | } 73 | 74 | if (startAt) { 75 | ref = ref.startAt(startAt); 76 | } 77 | 78 | if (endAt) { 79 | ref = ref.endAt(endAt); 80 | } 81 | 82 | if (startAfter) { 83 | ref = ref.startAfter(startAfter); 84 | } 85 | 86 | if (endBefore) { 87 | ref = ref.endBefore(endBefore); 88 | } 89 | 90 | if (limit) { 91 | ref = ref.limit(limit); 92 | } 93 | 94 | return ref; 95 | }; 96 | 97 | type ListenerReturnType = { 98 | initialData: Doc[]; 99 | unsubscribe: () => void; 100 | }; 101 | 102 | const createListenerAsync = async ( 103 | firestore: FirebaseFirestore, 104 | queryClient: QueryClient, 105 | path: string | undefined = undefined, 106 | queryString: string, 107 | ignoreFirestoreDocumentSnapshotField: boolean, 108 | setHasNextPage: (value: boolean) => void, 109 | ): Promise> => { 110 | return new Promise((resolve, reject) => { 111 | if (!path) { 112 | return resolve({ 113 | initialData: [], 114 | unsubscribe: empty.function, 115 | }); 116 | } 117 | const query: CollectionQueryType = JSON.parse(queryString) ?? {}; 118 | const ref = createFirestoreRef(firestore, path, query); 119 | const unsubscribe = ref.onSnapshot( 120 | {includeMetadataChanges: true}, 121 | { 122 | next: (querySnapshot) => { 123 | const data: Doc[] = []; 124 | 125 | querySnapshot.forEach((doc) => { 126 | const docData = doc.data() ?? empty.object; 127 | parseDates(docData); 128 | const docToAdd = { 129 | ...docData, 130 | id: doc.id, 131 | exists: doc.exists, 132 | hasPendingWrites: doc.metadata.hasPendingWrites, 133 | __snapshot: ignoreFirestoreDocumentSnapshotField 134 | ? undefined 135 | : doc, 136 | } as Doc; 137 | // update individual docs in the cache 138 | queryClient.setQueryData(doc.ref.path, docToAdd); 139 | data.push(docToAdd); 140 | }); 141 | 142 | setHasNextPage(!querySnapshot.empty); 143 | 144 | // resolve initial data 145 | resolve({ 146 | initialData: data, 147 | unsubscribe, 148 | }); 149 | // update on listener fire 150 | queryClient.setQueryData([path, queryString], data); 151 | }, 152 | error: reject, 153 | }, 154 | ); 155 | }); 156 | }; 157 | 158 | /** 159 | * Call a Firestore Collection 160 | * @template Doc 161 | * @param path String if the document is ready. If it's not ready yet, pass `null`, and the request won't start yet. 162 | * @param [query] - Dictionary with options to query the collection. 163 | * @param [options] - takes any of useQuery options. 164 | */ 165 | export const useCollection = ( 166 | path?: string, 167 | options?: Options[], Optional, "id">[]>, 168 | query?: CollectionQueryType> & { 169 | ignoreFirestoreDocumentSnapshotField?: boolean; 170 | }, 171 | ) => { 172 | const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); 173 | const [hasNextPage, setHasNextPage] = useState(true); 174 | const {firestore} = useFirestore(); 175 | const queryClient = useQueryClient(); 176 | const unsubscribeRef = useRef( 177 | null, 178 | ); 179 | 180 | const { 181 | where, 182 | endAt, 183 | endBefore, 184 | startAfter, 185 | startAt, 186 | orderBy, 187 | limit, 188 | isCollectionGroup, 189 | ignoreFirestoreDocumentSnapshotField = true, 190 | } = query || {}; 191 | 192 | // why not just put this into the ref directly? 193 | // so that we can use the useEffect down below that triggers revalidate() 194 | const memoQueryString = useMemo( 195 | () => 196 | JSON.stringify({ 197 | where, 198 | endAt, 199 | endBefore, 200 | startAfter, 201 | startAt, 202 | orderBy, 203 | limit, 204 | isCollectionGroup, 205 | }), 206 | [ 207 | endAt, 208 | endBefore, 209 | isCollectionGroup, 210 | limit, 211 | orderBy, 212 | startAfter, 213 | startAt, 214 | where, 215 | ], 216 | ); 217 | 218 | async function fetch() { 219 | if (unsubscribeRef.current) { 220 | unsubscribeRef.current(); 221 | unsubscribeRef.current = null; 222 | } 223 | const {unsubscribe, initialData} = await createListenerAsync< 224 | Document 225 | >( 226 | firestore, 227 | queryClient, 228 | path, 229 | memoQueryString, 230 | ignoreFirestoreDocumentSnapshotField, 231 | setHasNextPage, 232 | ); 233 | unsubscribeRef.current = unsubscribe; 234 | return initialData; 235 | } 236 | 237 | const {data, status, error} = useQuery< 238 | Document[], 239 | Error, 240 | Optional, "id">[] 241 | >([path, memoQueryString], fetch, { 242 | ...options, 243 | notifyOnChangeProps: "tracked", 244 | }); 245 | 246 | const {mutateAsync} = useMutation< 247 | Document[], 248 | Error, 249 | {data: Data | Data[]; subPath?: string} 250 | >( 251 | async ({data: newData, subPath}) => { 252 | if (!path) return Promise.resolve([]); 253 | const newPath = subPath ? path + "/" + subPath : path; 254 | const dataArray = Array.isArray(newData) ? newData : [newData]; 255 | 256 | const ref = firestore.collection(newPath); 257 | const docsToAdd: Document[] = dataArray.map((doc) => ({ 258 | ...doc, 259 | // generate IDs we can use that in the local cache that match the server 260 | id: ref.doc().id, 261 | })); 262 | 263 | // add to network 264 | const batch = firestore.batch(); 265 | 266 | docsToAdd.forEach(({id, ...doc}) => { 267 | // take the ID out of the document 268 | batch.set(ref.doc(id), doc); 269 | }); 270 | 271 | await batch.commit(); 272 | 273 | return Promise.resolve(docsToAdd); 274 | }, 275 | { 276 | // Always refetch after error or success: 277 | onSettled: () => { 278 | queryClient.invalidateQueries([path, memoQueryString]); 279 | }, 280 | }, 281 | ); 282 | 283 | useEffect(() => { 284 | //should it go before the useQuery? 285 | return () => { 286 | // clean up listener on unmount if it exists 287 | if (unsubscribeRef.current) { 288 | unsubscribeRef.current(); 289 | unsubscribeRef.current = null; 290 | } 291 | }; 292 | // should depend on the path, queyr being the same... 293 | }, [path, memoQueryString]); 294 | 295 | // add the collection to the cache, 296 | // so that we can mutate it from document calls later 297 | useEffect(() => { 298 | if (path) collectionCache.addCollectionToCache(path, memoQueryString); 299 | }, [path, memoQueryString]); 300 | 301 | /** 302 | * `add(data, subPath?)`: Extends the Firestore document [`add` function](https://firebase.google.com/docs/firestore/manage-data/add-data). 303 | * - It also updates the local cache using react-query's `setQueryData`. This will prove highly convenient over the regular `add` function provided by Firestore. 304 | * - If the second argument is defined it will be concatinated to path arg as a prefix 305 | */ 306 | const add = async (newData: Data | Data[], subPath?: string) => 307 | mutateAsync({data: newData, subPath}); 308 | 309 | const setCache = useCallback( 310 | (cachedData: TransData[]) => { 311 | queryClient.setQueryData( 312 | [path, memoQueryString], 313 | (prevState) => { 314 | if (!prevState) return []; 315 | return [...prevState, ...cachedData]; 316 | }, 317 | ); 318 | }, 319 | // eslint-disable-next-line react-hooks/exhaustive-deps 320 | [path, memoQueryString], 321 | ); 322 | 323 | const fetchNextPage = async () => { 324 | if (!path || !data?.length) return; 325 | 326 | setIsFetchingNextPage(true); 327 | 328 | // get the snapshot of last document we have right now in our query 329 | const startAfterDocument = data[data.length - 1].__snapshot; 330 | const ref = createFirestoreRef( 331 | firestore, 332 | path, 333 | JSON.parse(memoQueryString), 334 | ); 335 | 336 | // get more documents, after the most recent one we have 337 | const querySnapshot = await ref.startAfter(startAfterDocument).get(); 338 | setHasNextPage(!querySnapshot.empty); 339 | const moreDocs: TransData[] = []; 340 | querySnapshot.docs.forEach((doc) => { 341 | const docData = doc.data() ?? empty.object; 342 | const docToAdd = { 343 | ...docData, 344 | id: doc.id, 345 | exists: doc.exists, 346 | hasPendingWrites: doc.metadata.hasPendingWrites, 347 | __snapshot: ignoreFirestoreDocumentSnapshotField 348 | ? undefined 349 | : doc, 350 | } as Document; 351 | // update individual docs in the cache 352 | queryClient.setQueryData(doc.ref.path, docToAdd); 353 | moreDocs.push(docToAdd); 354 | }); 355 | 356 | // mutate our local cache, adding the docs we just added 357 | setCache(moreDocs); 358 | 359 | setIsFetchingNextPage(false); 360 | }; 361 | 362 | return { 363 | data, 364 | status, 365 | error, 366 | add, 367 | setCache, 368 | fetchNextPage, 369 | isFetchingNextPage, 370 | hasNextPage, 371 | /** 372 | * A function that, when called, unsubscribes the Firestore listener. 373 | * 374 | * The function can be null, so make sure to check that it exists before calling it. 375 | * 376 | * Note: This is not necessary to use. `useCollection` already unmounts the listener for you. This is only intended if you want to unsubscribe on your own. 377 | */ 378 | unsubscribe: unsubscribeRef.current, 379 | }; 380 | }; 381 | -------------------------------------------------------------------------------- /src/useDocument.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useRef} from "react"; 2 | import {useQuery, useQueryClient, QueryClient} from "react-query"; 3 | import { 4 | FieldValue, 5 | FirebaseFirestore, 6 | SetOptions, 7 | } from "@firebase/firestore-types"; 8 | 9 | import {collectionCache} from "./Cache"; 10 | import {ListenerReturnType, AllowType, Document, empty, Options} from "./types"; 11 | import {useHelpers} from "./useHelpers"; 12 | import {useFirestore} from "./Provider"; 13 | import {parseDates} from "./utils"; 14 | 15 | function updateCollectionCache( 16 | queryClient: QueryClient, 17 | path: string, 18 | docId: string, 19 | data: any, 20 | ) { 21 | let collection: string | string[] = path.split(`/${docId}`).filter(Boolean); 22 | collection = collection.join("/"); 23 | 24 | if (collection) { 25 | collectionCache.getKeysFromCollectionPath(collection).forEach((key) => { 26 | queryClient.setQueryData( 27 | key, 28 | (currentState = empty.array) => { 29 | // don't mutate the current state if it doesn't include this doc 30 | if ( 31 | !currentState.some( 32 | (currDoc) => currDoc.id && currDoc.id === data.id, 33 | ) 34 | ) { 35 | return currentState; 36 | } 37 | return currentState.map((document) => { 38 | if (document.id === data.id) { 39 | return data; 40 | } 41 | return document; 42 | }); 43 | }, 44 | ); 45 | }); 46 | } 47 | } 48 | 49 | const createListenerAsync = async ( 50 | firestore: FirebaseFirestore, 51 | queryClient: QueryClient, 52 | path?: string, 53 | ): Promise> => { 54 | if (!path) { 55 | return {unsubscribe: empty.function, initialData: empty.object as Doc}; 56 | } 57 | return await new Promise((resolve, reject) => { 58 | const unsubscribe = firestore.doc(path).onSnapshot( 59 | {includeMetadataChanges: true}, 60 | (doc) => { 61 | const docData = doc.data() ?? empty.object; 62 | parseDates(docData); 63 | const data = { 64 | ...docData, 65 | id: doc.id, 66 | exists: doc.exists, 67 | hasPendingWrites: doc.metadata.hasPendingWrites, 68 | } as Doc; 69 | if (!data.hasPendingWrites) { 70 | queryClient.setQueryData(path, data); 71 | updateCollectionCache(queryClient, path, doc.id, data); 72 | resolve({ 73 | initialData: data, 74 | unsubscribe, 75 | }); 76 | } 77 | }, 78 | reject, 79 | ); 80 | }); 81 | }; 82 | 83 | export const useDocument = >( 84 | path?: string, 85 | options?: Options, TransData>, 86 | ) => { 87 | const {firestore} = useFirestore(); 88 | const {deleteDocument: deleteDoc} = useHelpers(); 89 | const queryClient = useQueryClient(); 90 | const unsubscribeRef = useRef( 91 | null, 92 | ); 93 | 94 | useEffect(() => { 95 | return () => { 96 | // clean up listener on unmount if it exists 97 | if (unsubscribeRef.current) { 98 | unsubscribeRef.current(); 99 | unsubscribeRef.current = null; 100 | } 101 | }; 102 | }, [path]); 103 | 104 | async function fetch() { 105 | if (unsubscribeRef.current) { 106 | unsubscribeRef.current(); 107 | unsubscribeRef.current = null; 108 | } 109 | const listner = await createListenerAsync>( 110 | firestore, 111 | queryClient, 112 | path, 113 | ); 114 | unsubscribeRef.current = listner.unsubscribe; 115 | return listner.initialData; 116 | } 117 | 118 | const {data, status, error, refetch} = useQuery< 119 | Document, 120 | Error, 121 | TransData 122 | >(path || "", fetch, { 123 | ...options, 124 | enabled: !!path && options?.enabled, 125 | notifyOnChangeProps: "tracked", 126 | }); 127 | 128 | const set = useCallback( 129 | ( 130 | newData: Partial>, 131 | setOptions?: SetOptions, 132 | ) => { 133 | if (!path) return; 134 | return firestore.doc(path).set(newData, setOptions || {}); 135 | }, 136 | [path, firestore], 137 | ); 138 | 139 | const update = useCallback( 140 | (newData: Partial>) => { 141 | if (!path) return; 142 | return firestore.doc(path).update(newData); 143 | }, 144 | [path, firestore], 145 | ); 146 | 147 | const deleteDocument = useCallback(() => { 148 | return deleteDoc(path); 149 | // eslint-disable-next-line react-hooks/exhaustive-deps 150 | }, [path]); 151 | 152 | const setCache = useCallback( 153 | (cachedData: Partial>) => { 154 | if (!path) return; 155 | const newData = queryClient.setQueryData>>( 156 | path, 157 | (prevState) => { 158 | return { 159 | ...prevState, 160 | ...cachedData, 161 | }; 162 | }, 163 | ); 164 | updateCollectionCache(queryClient, path, newData.id || "", newData); 165 | }, 166 | // eslint-disable-next-line react-hooks/exhaustive-deps 167 | [path], 168 | ); 169 | 170 | return { 171 | data, 172 | status, 173 | error, 174 | set, 175 | update, 176 | deleteDocument, 177 | refetch, 178 | setCache, 179 | unsubscribe: unsubscribeRef.current, 180 | }; 181 | }; 182 | -------------------------------------------------------------------------------- /src/useHelpers.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {useQueryClient, QueryClient} from "react-query"; 3 | import {SetOptions} from "@firebase/firestore-types"; 4 | 5 | import {collectionCache} from "./Cache"; 6 | import {empty, FirebaseHelpersOptions} from "./types"; 7 | import {useFirestore} from "./Provider"; 8 | 9 | export function useIsMounted() { 10 | const mountedRef = React.useRef(false); 11 | const isMounted = React.useCallback(() => mountedRef.current, []); 12 | 13 | React.useEffect(() => { 14 | mountedRef.current = true; 15 | return () => { 16 | mountedRef.current = false; 17 | }; 18 | }); 19 | 20 | return isMounted; 21 | } 22 | 23 | function updateCollectionCache>( 24 | queryClient: QueryClient, 25 | path: string, 26 | callback: (currentState: Doc[], docId?: string) => Doc[], 27 | identityState?: string, 28 | identityField = "id", 29 | ) { 30 | let collection: string | string[] = path.split("/").filter(Boolean); 31 | const docId = collection.pop(); // remove last item, which is the /doc-id 32 | collection = collection.join("/"); 33 | 34 | collectionCache.getKeysFromCollectionPath(collection).forEach((key) => { 35 | queryClient.setQueryData(key, (currentState: Doc[] = empty.array) => { 36 | const state = 37 | identityState && !Array.isArray(currentState) 38 | ? currentState[identityState] 39 | : currentState; 40 | // don't mutate the current state if it doesn't include this doc 41 | // why? to prevent creating a new reference of the state 42 | // creating a new reference could trigger unnecessary re-renders 43 | if (!state?.some((doc: Doc) => doc[identityField] === docId)) { 44 | return currentState; 45 | } 46 | 47 | const queryState = callback(state, docId); 48 | return identityState 49 | ? { 50 | ...currentState, 51 | [identityState]: queryState, 52 | } 53 | : queryState; 54 | }); 55 | }); 56 | } 57 | 58 | export const useHelpers = () => { 59 | const {firestore} = useFirestore(); 60 | const queryClient = useQueryClient(); 61 | 62 | /** 63 | * `addToCollectionCache(path, queryString?)`: wrapper for addCollectionToCache from internal Cache. 64 | * you cann call this when you want to add a collection to the cache if it was not requested 65 | * by firestore, e.g. a direct axios call. 66 | * so that we can mutate it from document calls later 67 | */ 68 | const addToCollectionCache = (path: string, queryString?: string) => { 69 | collectionCache.addCollectionToCache(path, queryString); 70 | }; 71 | 72 | /** 73 | * `setDocument(path, data, SetOptions?)`: Extends the `firestore` document `set` function. 74 | * - You can call this when you want to edit your document. 75 | * - It also updates the local cache using. This will prove highly convenient over the regular Firestore `set` function. 76 | * - The third argument is the same as the second argument for [Firestore `set`](https://firebase.google.com/docs/firestore/manage-data/add-data#set_a_document). 77 | */ 78 | const setDocument = ( 79 | path: string | undefined, 80 | data: Partial, 81 | options?: SetOptions, 82 | { 83 | ignoreLocalMutation = false, 84 | identityState, 85 | identityField = "id", 86 | }: FirebaseHelpersOptions = {}, 87 | ) => { 88 | if (!path) return undefined; 89 | 90 | const isDocument = 91 | path.trim().split("/").filter(Boolean).length % 2 === 0; 92 | 93 | if (!isDocument) 94 | throw new Error( 95 | `[react-query-firestore] error: called set() function with path: ${path}. This is not a valid document path. data: ${JSON.stringify( 96 | data, 97 | )}`, 98 | ); 99 | 100 | if (!ignoreLocalMutation) { 101 | queryClient.setQueryData>(path, (prevState) => { 102 | // default we set merge to be false. this is annoying, but follows Firestore's preference. 103 | if (!options?.merge) return data; 104 | return { 105 | ...prevState, 106 | ...data, 107 | }; 108 | }); 109 | } 110 | 111 | updateCollectionCache( 112 | queryClient, 113 | path, 114 | (currentState, docId) => { 115 | return currentState.map((document) => { 116 | if (document[identityField] === docId) { 117 | if (!options?.merge) return document; 118 | return {...document, ...data}; 119 | } 120 | return document; 121 | }); 122 | }, 123 | identityState, 124 | identityField, 125 | ); 126 | 127 | return firestore.doc(path).set(data, options || {}); 128 | }; 129 | 130 | /** 131 | * - `updateDocument(path, data)`: Extends the Firestore document [`update` function](https://firebase.google.com/docs/firestore/manage-data/add-data#update-data). 132 | * - It also updates the local cache using. This will prove highly convenient over the regular `set` function. 133 | */ 134 | const updateDocument = ( 135 | path: string | undefined, 136 | data: Partial, 137 | { 138 | ignoreLocalMutation = false, 139 | identityState, 140 | identityField = "id", 141 | }: FirebaseHelpersOptions = {}, 142 | ) => { 143 | if (!path) return undefined; 144 | const isDocument = 145 | path.trim().split("/").filter(Boolean).length % 2 === 0; 146 | 147 | if (!isDocument) 148 | throw new Error( 149 | `[react-query-firestore] error: called update function with path: ${path}. This is not a valid document path. data: ${JSON.stringify( 150 | data, 151 | )}`, 152 | ); 153 | 154 | if (!ignoreLocalMutation) { 155 | queryClient.setQueryData>(path, (prevState) => { 156 | return { 157 | ...prevState, 158 | ...data, 159 | }; 160 | }); 161 | } 162 | 163 | updateCollectionCache( 164 | queryClient, 165 | path, 166 | (currentState, docId) => { 167 | return currentState.map((document) => { 168 | if (document[identityField] === docId) { 169 | return {...document, ...data}; 170 | } 171 | return document; 172 | }); 173 | }, 174 | identityState, 175 | identityField, 176 | ); 177 | 178 | return firestore.doc(path).update(data); 179 | }; 180 | 181 | const deleteDocument = ( 182 | path: string | undefined, 183 | { 184 | ignoreLocalMutation = false, 185 | identityState, 186 | identityField = "id", 187 | }: FirebaseHelpersOptions = {}, 188 | ) => { 189 | if (!path) return undefined; 190 | 191 | const isDocument = 192 | path.trim().split("/").filter(Boolean).length % 2 === 0; 193 | 194 | if (!isDocument) 195 | throw new Error( 196 | `[react-query-firestore] error: called delete() function with path: ${path}. This is not a valid document path.`, 197 | ); 198 | 199 | if (!ignoreLocalMutation) { 200 | queryClient.setQueryData(path, null); 201 | } 202 | 203 | updateCollectionCache( 204 | queryClient, 205 | path, 206 | (currentState, docId) => { 207 | return currentState.filter((document) => { 208 | if (!document) return false; 209 | if (document[identityField] === docId) { 210 | // delete this doc 211 | return false; 212 | } 213 | return true; 214 | }); 215 | }, 216 | identityState, 217 | identityField, 218 | ); 219 | 220 | return firestore.doc(path).delete(); 221 | }; 222 | 223 | return { 224 | setDocument, 225 | updateDocument, 226 | deleteDocument, 227 | addToCollectionCache, 228 | }; 229 | }; 230 | -------------------------------------------------------------------------------- /src/useInfiniteCollection.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useMemo, useRef} from "react"; 2 | import { 3 | QueryClient, 4 | useInfiniteQuery, 5 | useMutation, 6 | useQueryClient, 7 | } from "react-query"; 8 | import { 9 | DocumentData, 10 | FieldPath, 11 | FirebaseFirestore, 12 | Query, 13 | QueryDocumentSnapshot, 14 | } from "@firebase/firestore-types"; 15 | 16 | import {useFirestore} from "./Provider"; 17 | import {collectionCache} from "./Cache"; 18 | import { 19 | CollectionQueryType, 20 | Document, 21 | empty, 22 | InfiniteOptions, 23 | OrderByArray, 24 | OrderByType, 25 | WhereArray, 26 | WhereType, 27 | } from "./types"; 28 | import {parseDates, unionBy} from "./utils"; 29 | 30 | const createFirestoreRef = ( 31 | firestore: FirebaseFirestore, 32 | path: string, 33 | { 34 | where, 35 | orderBy, 36 | limit, 37 | startAt, 38 | endAt, 39 | startAfter, 40 | endBefore, 41 | isCollectionGroup, 42 | }: CollectionQueryType, 43 | ) => { 44 | let ref: Query = firestore.collection(path); 45 | 46 | if (isCollectionGroup) { 47 | ref = firestore.collectionGroup(path); 48 | } 49 | 50 | if (where) { 51 | function multipleConditions(w: WhereType): w is WhereArray { 52 | return !!(w as WhereArray) && Array.isArray(w[0]); 53 | } 54 | if (multipleConditions(where)) { 55 | where.forEach((w) => { 56 | ref = ref.where(w[0] as string | FieldPath, w[1], w[2]); 57 | }); 58 | } else if ( 59 | typeof where[0] === "string" && 60 | typeof where[1] === "string" 61 | ) { 62 | ref = ref.where(where[0], where[1], where[2]); 63 | } 64 | } 65 | 66 | if (orderBy) { 67 | if (typeof orderBy === "string") { 68 | ref = ref.orderBy(orderBy); 69 | } else if (Array.isArray(orderBy)) { 70 | function multipleOrderBy(o: OrderByType): o is OrderByArray[] { 71 | return Array.isArray((o as OrderByArray[])[0]); 72 | } 73 | if (multipleOrderBy(orderBy)) { 74 | orderBy.forEach(([order, direction]) => { 75 | ref = ref.orderBy(order as string | FieldPath, direction); 76 | }); 77 | } else { 78 | const [order, direction] = orderBy; 79 | ref = ref.orderBy(order as string | FieldPath, direction); 80 | } 81 | } 82 | } 83 | 84 | if (startAt) { 85 | ref = ref.startAt(startAt); 86 | } 87 | 88 | if (endAt) { 89 | ref = ref.endAt(endAt); 90 | } 91 | 92 | if (startAfter) { 93 | ref = ref.startAfter(startAfter); 94 | } 95 | 96 | if (endBefore) { 97 | ref = ref.endBefore(endBefore); 98 | } 99 | 100 | if (limit) { 101 | ref = ref.limit(limit); 102 | } 103 | 104 | return ref; 105 | }; 106 | 107 | type ListenerReturnType = { 108 | data: Doc[]; 109 | lastDoc?: QueryDocumentSnapshot; 110 | }; 111 | 112 | const createListenerAsync = async ( 113 | firestore: FirebaseFirestore, 114 | queryClient: QueryClient, 115 | path: string | undefined = undefined, 116 | queryString: string, 117 | pageParam: any, 118 | ): Promise> => { 119 | return new Promise(async (resolve) => { 120 | if (!path) { 121 | return resolve({ 122 | data: [], 123 | }); 124 | } 125 | const query: CollectionQueryType = JSON.parse(queryString) ?? {}; 126 | let ref = createFirestoreRef(firestore, path, query); 127 | if (pageParam) { 128 | ref = ref.startAfter(pageParam); 129 | } 130 | 131 | const docs = await ref.get(); 132 | const response: Doc[] = []; 133 | docs.forEach((doc) => { 134 | const docData = doc.data() ?? empty.object; 135 | parseDates(docData); 136 | const docToAdd = { 137 | ...docData, 138 | id: doc.id, 139 | exists: doc.exists, 140 | hasPendingWrites: doc.metadata.hasPendingWrites, 141 | __snapshot: doc, 142 | } as Doc; 143 | // update individual docs in the cache 144 | queryClient.setQueryData(doc.ref.path, docToAdd); 145 | response.push(docToAdd); 146 | }); 147 | 148 | resolve({ 149 | data: response, 150 | lastDoc: response[response.length - 1]?.__snapshot, 151 | }); 152 | }); 153 | }; 154 | 155 | /** 156 | * Call a Firestore Collection 157 | * @template Doc 158 | * @param path String if the document is ready. If it's not ready yet, pass `undefined`, and the request won't start yet. 159 | * @param [options] - takes any of useQuery options. 160 | * @param [query] - Dictionary with options to query the collection. 161 | */ 162 | export const useInfiniteCollection = ( 163 | path?: string, 164 | options?: InfiniteOptions< 165 | {data: Document[]; lastDoc?: QueryDocumentSnapshot}, 166 | {data: Document} 167 | >, 168 | query?: CollectionQueryType>, 169 | ) => { 170 | const {firestore} = useFirestore(); 171 | const queryClient = useQueryClient(); 172 | const unsubscribeRef = useRef<() => void>(); 173 | const docsToAddRef = useRef[]>([]); 174 | 175 | const { 176 | where, 177 | endAt, 178 | endBefore, 179 | startAfter, 180 | startAt, 181 | orderBy, 182 | limit, 183 | isCollectionGroup, 184 | } = query || {}; 185 | 186 | // why not just put this into the ref directly? 187 | // so that we can use the useEffect down below that triggers revalidate() 188 | const memoQueryString = useMemo( 189 | () => 190 | JSON.stringify({ 191 | where, 192 | endAt, 193 | endBefore, 194 | startAfter, 195 | startAt, 196 | orderBy, 197 | limit, 198 | isCollectionGroup, 199 | }), 200 | [ 201 | endAt, 202 | endBefore, 203 | isCollectionGroup, 204 | limit, 205 | orderBy, 206 | startAfter, 207 | startAt, 208 | where, 209 | ], 210 | ); 211 | 212 | async function fetch({pageParam}: any) { 213 | const response = await createListenerAsync>( 214 | firestore, 215 | queryClient, 216 | path, 217 | memoQueryString, 218 | pageParam, 219 | ); 220 | return response; 221 | } 222 | 223 | const { 224 | data, 225 | status, 226 | error, 227 | fetchNextPage, 228 | hasNextPage, 229 | isFetchingNextPage, 230 | } = useInfiniteQuery< 231 | {data: Document[]; lastDoc?: QueryDocumentSnapshot}, 232 | Error, 233 | {data: Document} 234 | >([path, memoQueryString], fetch, { 235 | ...options, 236 | notifyOnChangeProps: "tracked", 237 | getNextPageParam: (lastPage) => { 238 | return lastPage.lastDoc; 239 | }, 240 | }); 241 | 242 | const {mutateAsync} = useMutation< 243 | Document[], 244 | Error, 245 | {data: Data | Data[]; subPath?: string}, 246 | {previousPages: any} 247 | >( 248 | async ({subPath}) => { 249 | if (!path) return Promise.resolve([]); 250 | const newPath = subPath ? path + "/" + subPath : path; 251 | const ref = firestore.collection(newPath); 252 | 253 | // add to network 254 | const batch = firestore.batch(); 255 | docsToAddRef.current.forEach(({id, ...doc}) => { 256 | // take the ID out of the document 257 | batch.set(ref.doc(id), doc); 258 | }); 259 | await batch.commit(); 260 | 261 | return Promise.resolve(docsToAddRef.current); 262 | }, 263 | { 264 | // When mutate is called: 265 | onMutate: async ({data: newData, subPath}) => { 266 | // Cancel any outgoing refetches (so they don't overwrite our optimistic update) 267 | await queryClient.cancelQueries([path, memoQueryString]); 268 | 269 | // Snapshot the previous value 270 | const previousPages = queryClient.getQueryData<{ 271 | pages: { 272 | data: Document[]; 273 | lastDoc?: QueryDocumentSnapshot; 274 | }[]; 275 | pageParams: any; 276 | }>([path, memoQueryString]) || {pages: [], pageParams: []}; 277 | 278 | if (!path) return {previousPages}; 279 | const newPath = subPath ? path + "/" + subPath : path; 280 | const dataArray = Array.isArray(newData) ? newData : [newData]; 281 | 282 | const ref = firestore.collection(newPath); 283 | docsToAddRef.current = dataArray.map((doc) => ({ 284 | ...doc, 285 | // generate IDs we can use that in the local cache that match the server 286 | id: ref.doc().id, 287 | })); 288 | 289 | // Optimistically update to the new value 290 | const [firstPage, ...restPage] = previousPages.pages; 291 | const newDataa = { 292 | ...previousPages, 293 | pages: [ 294 | { 295 | ...firstPage, 296 | data: [...docsToAddRef.current, ...firstPage.data], 297 | }, 298 | ...restPage, 299 | ], 300 | }; 301 | queryClient.setQueryData([path, memoQueryString], newDataa); 302 | 303 | // Return a context object with the snapshotted value 304 | return {previousPages}; 305 | }, 306 | 307 | // If the mutation fails, use the context returned from onMutate to roll back 308 | onError: (_, __, context) => { 309 | queryClient.setQueryData( 310 | [path, memoQueryString], 311 | context?.previousPages, 312 | ); 313 | }, 314 | }, 315 | ); 316 | 317 | useEffect(() => { 318 | if (!path) return; 319 | const ref = createFirestoreRef( 320 | firestore, 321 | path, 322 | JSON.parse(memoQueryString) ?? {}, 323 | ); 324 | 325 | unsubscribeRef.current = ref.onSnapshot( 326 | {includeMetadataChanges: false}, 327 | { 328 | next: (querySnapshot) => { 329 | const results: Document[] = []; 330 | querySnapshot.docChanges().forEach(({doc, type}) => { 331 | if (type === "added") { 332 | const docData = doc.data() ?? empty.object; 333 | parseDates(docData); 334 | const docToAdd = { 335 | ...docData, 336 | id: doc.id, 337 | exists: doc.exists, 338 | hasPendingWrites: doc.metadata.hasPendingWrites, 339 | __snapshot: doc, 340 | } as Document; 341 | queryClient.setQueryData(doc.ref.path, docToAdd); 342 | results.push(docToAdd); 343 | } 344 | }); 345 | 346 | queryClient.setQueryData<{ 347 | pages: { 348 | data: Document[]; 349 | lastDoc?: QueryDocumentSnapshot; 350 | }[]; 351 | pageParams: any; 352 | }>([path, memoQueryString], (resp) => { 353 | if (!resp) return resp as any; 354 | const [firstPage, ...restPage] = resp.pages; 355 | return { 356 | ...resp, 357 | pages: [ 358 | { 359 | ...firstPage, 360 | data: unionBy( 361 | results, 362 | firstPage.data, 363 | (doc) => doc.id, 364 | ), 365 | }, 366 | ...restPage, 367 | ], 368 | }; 369 | }); 370 | }, 371 | }, 372 | ); 373 | 374 | //should it go before the useQuery? 375 | return () => { 376 | // clean up listener on unmount if it exists 377 | if (unsubscribeRef.current) { 378 | unsubscribeRef.current(); 379 | } 380 | }; 381 | // should depend on the path, queyr being the same... 382 | // eslint-disable-next-line react-hooks/exhaustive-deps 383 | }, [path, memoQueryString]); 384 | 385 | // add the collection to the cache, 386 | // so that we can mutate it from document calls later 387 | useEffect(() => { 388 | if (path) collectionCache.addCollectionToCache(path, memoQueryString); 389 | }, [path, memoQueryString]); 390 | 391 | /** 392 | * `add(data, subPath?)`: Extends the Firestore document [`add` function](https://firebase.google.com/docs/firestore/manage-data/add-data). 393 | * - It also updates the local cache using react-query's `setQueryData`. This will prove highly convenient over the regular `add` function provided by Firestore. 394 | * - If the second argument is defined it will be concatinated to path arg as a prefix 395 | */ 396 | const add = async (newData: Data | Data[], subPath?: string) => 397 | mutateAsync({data: newData, subPath}); 398 | 399 | const formattedData = useMemo(() => { 400 | return data?.pages.map((page) => page.data).flat(); 401 | }, [data]); 402 | 403 | return { 404 | data: formattedData, 405 | status, 406 | error, 407 | add, 408 | fetchNextPage, 409 | hasNextPage, 410 | isFetchingNextPage, 411 | /** 412 | * A function that, when called, unsubscribes the Firestore listener. 413 | * 414 | * The function can be null, so make sure to check that it exists before calling it. 415 | * 416 | * Note: This is not necessary to use. `useCollection` already unmounts the listener for you. This is only intended if you want to unsubscribe on your own. 417 | */ 418 | unsubscribe: unsubscribeRef.current, 419 | }; 420 | }; 421 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import {DocumentData} from "@firebase/firestore-types"; 2 | 3 | export function parseDates(obj: DocumentData) { 4 | const keys = Object.keys(obj); 5 | keys.forEach(function (key) { 6 | const value = obj[key]; 7 | if (!value) return value; 8 | if ( 9 | typeof value === "object" && 10 | "seconds" in value && 11 | "nanoseconds" in value 12 | ) { 13 | obj[key] = value.toDate(); 14 | } else if (typeof value === "object") { 15 | parseDates(obj[key]); 16 | } 17 | }); 18 | } 19 | 20 | export function unionBy(arr1: T[], arr2: T[], iteratee: (item: T) => any) { 21 | const set = new Set(arr1.map(iteratee)); 22 | return Array.from( 23 | new Set([...arr1, ...arr2.filter((itm) => !set.has(iteratee(itm)))]), 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "allowUnreachableCode": false, 5 | "allowSyntheticDefaultImports": true, 6 | "allowUnusedLabels": false, 7 | "esModuleInterop": true, 8 | "isolatedModules": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "jsx": "react", 11 | "lib": [ 12 | "esnext" 13 | ], 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitReturns": true, 18 | "noImplicitUseStrict": false, 19 | "noStrictGenericChecks": false, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "resolveJsonModule": true, 23 | "skipLibCheck": true, 24 | "strict": true, 25 | "target": "esnext", 26 | "strictNullChecks": true 27 | }, 28 | "exclude": [ 29 | "node_modules", 30 | "examples" 31 | ] 32 | } --------------------------------------------------------------------------------