├── .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 | }
--------------------------------------------------------------------------------