SWR + Firestore
const { data } = useDocument('users/fernando')
It's that easy.
You can now fetch, add, and mutate Firestore data with zero boilerplate.
Features
- Shared state / cache between collection and document queries (instead of Redux??)
- Works with both React and React Native.
- Offline mode with Expo (without detaching!)
- Blazing fast
- Query collection groups (new in
0.14.x
!) set
,update
, andadd
update your global cache, instantly- TypeScript-ready (see docs)
- Realtime subscriptions (example)
- Prevent memory leaks from Firestore subscriptions
- No more parsing
document.data()
from Firestore requests - Server-side rendering (SSR or SSG) with Next.js (example)
- Automatic date parsing (no more
.toDate()
) - Firebase v8 support (see #59)
...along with the features touted by Vercel's incredible SWR library:
"With SWR, components will get a stream of data updates constantly and automatically. Thus, the UI will be always fast and reactive."
- Transport and protocol agnostic data fetching
- Fast page navigation
- Revalidation on focus
- Interval polling
- Request deduplication
- Local mutation
- Pagination
- TypeScript ready
- SSR support
- Suspense mode
- Minimal API
โญ๏ธ
If you like this library, give it star and let me know on Twitter!
Installation
yarn add @nandorojo/swr-firestore
# or
npm install @nandorojo/swr-firestore
Install firebase:
# if you're using expo:
expo install firebase
# if you aren't using expo:
yarn add firebase
# or
npm i firebase
Set up
In the root of your app, create an instance of Fuego and pass it to the FuegoProvider.
If you're using Firebase v8, see this solution for creating your instance of Fuego
.
If you're using next.js
, this goes in your pages/_app.js
file.
App.js
import React from 'react'
import 'firebase/firestore'
import 'firebase/auth'
import { Fuego, FuegoProvider } from '@nandorojo/swr-firestore'
const firebaseConfig = {
// put yours here
}
const fuego = new Fuego(firebaseConfig)
export default function App() {
return (
<FuegoProvider fuego={fuego}>
<YourAppHere />
</FuegoProvider>
)
}
Make sure to create your Fuego
instance outside of the component. The only argument Fuego
takes is your firebase config
variable.
Under the hood, this step initializes firebase for you. No need to call firebase.initializeApp
.
Basic Usage
Assuming you've already completed the setup...
Subscribe to a document
import React from 'react'
import { useDocument } from '@nandorojo/swr-firestore'
import { Text } from 'react-native'
export default function User() {
const user = { id: 'Fernando' }
const { data, update, error } = useDocument(`users/${user.id}`, {
listen: true,
})
if (error) return <Text>Error!</Text>
if (!data) return <Text>Loading...</Text>
return <Text>Name: {data.name}</Text>
}
Get a collection
import React from 'react'
import { useCollection } from '@nandorojo/swr-firestore'
import { Text } from 'react-native'
export default function UserList() {
const { data, update, error } = useCollection(`users`)
if (error) return <Text>Error!</Text>
if (!data) return <Text>Loading...</Text>
return data.map(user => <Text key={user.id}>{user.name}</Text>)
}
useDocument
accepts a document path
as its first argument here. useCollection
works similarly.
Simple examples
Query a users collection:
const { data } = useCollection('users')
Subscribe for real-time updates:
const { data } = useDocument(`users/${user.id}`, { listen: true })
Make a complex collection query:
const { data } = useCollection('users', {
where: ['name', '==', 'fernando'],
limit: 10,
orderBy: ['age', 'desc'],
listen: true,
})
Pass options from SWR to your document query:
// pass SWR options
const { data } = useDocument('albums/nothing-was-the-same', {
shouldRetryOnError: false,
onSuccess: console.log,
loadingTimeout: 2000,
})
Pass options from SWR to your collection query:
// pass SWR options
const { data } = useCollection(
'albums',
{
listen: true,
// you can pass multiple where conditions if you want
where: [
['artist', '==', 'Drake'],
['year', '==', '2020'],
],
},
{
shouldRetryOnError: false,
onSuccess: console.log,
loadingTimeout: 2000,
}
)
Add data to your collection:
const { data, add } = useCollection('albums', {
where: ['artist', '==', 'Drake'],
})
const onPress = async () => {
// calling this will automatically update your global cache & Firestore
const documentId = await add({
title: 'Dark Lane Demo Tapes',
artist: 'Drake',
year: '2020',
})
}
Set document data:
const { data, set, update } = useDocument('albums/dark-lane-demo-tapes')
const onReleaseAlbum = () => {
// calling this will automatically update your global cache & Firestore
set(
{
released: true,
},
{ merge: true }
)
// or you could call this:
update({
released: true,
})
}
Use dynamic fields in a request:
If you pass null
as the collection or document key, the request won't send.
Once the key is set to a string, the request will send.
Get list of users who have you in their friends list
import { useDoormanUser } from 'react-doorman'
const { uid } = useDoormanUser()
const { data } = useCollection(uid ? 'users' : null, {
where: ['friends', 'array-contains', uid],
})
Get your favorite song
const me = { id: 'fernando' }
const { data: user } = useDocument<{ favoriteSong: string }>(`users/${me.id}`)
// only send the request once the user.favoriteSong exists!
const { data: song } = useDocument(
user?.favoriteSong ? `songs/${user.favoriteSong}` : null
)
Parse date fields in your documents
Magically turn any Firestore timestamps into JS date objects! No more .toDate()
.
Imagine your user
document schema looks like this:
type User = {
name: string
lastUpdated: {
date: Date
}
createdAt: Date
}
In order to turn createdAt
and lastUpdated.date
into JS objects, just use the parseDates
field:
In a document query
const { data } = useDocument<User>('user/fernando', {
parseDates: ['createdAt', 'lastUpdated.date'],
})
let createdAt: Date
if (data) {
// โ
all good! it's a JS Date now.
createdAt = data.createdAt
}
data.createdAt
and data.lastUpdated.date
are both JS dates now!
In a collection query
const { data } = useCollection<User>('user', {
parseDates: ['createdAt', 'lastUpdated.date'],
})
if (data) {
data.forEach(document => {
document.createdAt // JS date!
})
}
For more explanation on the dates, see issue #4.
Access a document's Firestore snapshot
If you set ignoreFirestoreDocumentSnapshotField
to false
, you can access the __snapshot
field.
const { data } = useDocument('users/fernando', {
ignoreFirestoreDocumentSnapshotField: false, // default: true
})
if (data) {
const id = data?.__snapshot.id
}
You can do the same for useCollection
and useCollectionGroup
. The snapshot will be on each item in the data
array.
This comes in handy when you are working with forms for data edits:
With Formik
const { data, set } = useDocument('users/fernando', {
ignoreFirestoreDocumentSnapshotField: false,
})
if (!data) return <Loading />
<Formik
initialValues={data.__snapshot.data()}
...
/>
With state and hooks
const { data, set } = useDocument('users/fernando', {
ignoreFirestoreDocumentSnapshotField: false,
})
const [values, setValues] = useState(null);
useEffect(() => {
if (data) {
setValues(data.__snapshot.data());
}
}, [data]);
Paginate a collection:
Video here.
import React from 'react'
import { fuego, useCollection } from '@nandorojo/swr-firestore'
const collection = 'dump'
const limit = 1
const orderBy = 'text'
export default function Paginate() {
const { data, mutate } = useCollection<{ text: string }>(
collection,
{
limit,
orderBy,
// ๐จ this is required to get access to the snapshot!
ignoreFirestoreDocumentSnapshotField: false,
},
{
// this lets us update the local cache + paginate without interruptions
revalidateOnFocus: false,
refreshWhenHidden: false,
refreshWhenOffline: false,
refreshInterval: 0,
}
)
const paginate = async () => {
if (!data?.length) return
const ref = fuego.db.collection(collection)
// get the snapshot of last document we have right now in our query
const startAfterDocument = data[data.length - 1].__snapshot
// get more documents, after the most recent one we have
const moreDocs = await ref
.orderBy(orderBy)
.startAfter(startAfterDocument)
.limit(limit)
.get()
.then(d => {
const docs = []
d.docs.forEach(doc => docs.push({ ...doc.data(), id: doc.id, __snapshot: doc }))
return docs
})
// mutate our local cache, adding the docs we just added
// set revalidate to false to prevent SWR from revalidating on its own
mutate(state => [...state, ...moreDocs], false)
}
return data ? (
<div>
{data.map(({ id, text }) => (
<div key={id}>{text}</div>
))}
<button onClick={paginate}>paginate</button>
</div>
) : (
<div>Loading...</div>
)
}
Query Documents
You'll rely on useDocument
to query documents.
import React from 'react'
import { useDocument } from '@nandorojo/swr-firestore'
const user = { id: 'Fernando' }
export default () => {
const { data, error } = useDocument(`users/${user.id}`)
}
If you want to set up a listener (or, in Firestore-speak, onSnapshot
) just set listen
to true
.
const { data, error } = useDocument(`users/${user.id}`, { listen: true })
API
Imports
import {
useDocument,
useCollection,
useCollectionGroup, // ๐ new!
revalidateDocument,
revalidateCollection,
// these all update BOTH Firestore & the local cache โก๏ธ
set, // set a firestore document
update, // update a firestore document
fuego, // get the firebase instance used by this lib
getCollection, // prefetch a collection, without being hooked into SWR or React
getDocument, // prefetch a document, without being hooked into SWR or React
} from '@nandorojo/swr-firestore'
useDocument(path, options)
const {
data,
set,
update,
deleteDocument,
error,
isValidating,
mutate,
unsubscribe
} = useDocument(path, options)
Arguments
path
required The unique document path for your Firestore document.string
|null
. Ifnull
, the request will not be sent. This is useful if you want to get a user document, but the user ID hasn't loaded yet, for instance.- This follows the same pattern as the
key
argument inuseSWR
. See the SWR docs for more. Functions are not currently supported for this argument.
options
(optional) A dictionary with added options for the query. Takes the folowing values:listen = false
: Iftrue
, sets up a listener for this document that updates whenever it changes.- You can also pass any of the options available from
useSWR
. ignoreFirestoreDocumentSnapshotField = true
. See elaboration below.parseDates
: An array of string keys that correspond to dates in your document. Example.
ignoreFirestoreDocumentSnapshotField
If true
, docs returned in data
will not include the firestore __snapshot
field. If false
, it will include a __snapshot
field. This lets you access the document snapshot, but makes the document not JSON serializable.
By default, it ignores the __snapshot
field. This makes it easier for newcomers to use JSON.stringify
without weird errors. You must explicitly set it to false
to use it.
// include the firestore document snapshots
const { data } = useDocument('users/fernando', {
ignoreFirestoreDocumentSnapshotField: false,
})
if (data) {
const path = data.__snapshot.ref.path
}
The __snapshot
field is the exact snapshot returned by Firestore.
See Firestore's snapshot docs for more.
Return values
Returns a dictionary with the following values:
set(data, SetOptions?)
: Extends thefirestore
documentset
function.- You can call this when you want to edit your document.
- It also updates the local cache using SWR's
mutate
. This will prove highly convenient over the regular Firestoreset
function. - The second argument is the same as the second argument for Firestore
set
.
update(data)
: Extends the Firestore documentupdate
function.- It also updates the local cache using SWR's
mutate
. This will prove highly convenient over the regularset
function.
- It also updates the local cache using SWR's
deleteDocument()
: Extends the Firestore documentdelete
function.- It also updates the local cache using SWR's
mutate
by deleting your document from this query and all collection queries that have fetched this document. This will prove highly convenient over the regulardelete
function from Firestore.
- It also updates the local cache using SWR's
unsubscribe()
A function that, when called, unsubscribes the Firestore listener.- The function can be null, so make sure to check that it exists before calling it.
- Note: This is not necessary to use.
useDocument
already unmounts the listener for you. This is only intended if you want to unsubscribe on your own.
The dictionary also includes the following from useSWR
:
data
: data for the given key resolved by fetcher (or undefined if not loaded)error
: error thrown by fetcher (or undefined)isValidating
: if there's a request or revalidation loadingmutate(data?, shouldRevalidate?)
: function to mutate the cached data
useCollection(path, query, options)
const { data, add, error, isValidating, mutate, unsubscribe } = useCollection(
path,
query,
options
)
Arguments
path
required string, path to collection.query
optional dictionary with Firestore query detailsoptions
SWR options (see SWR docs)
path
path
required The unique document path for your Firestore document.
string
|null
. Ifnull
, the request will not be sent. This is useful if you want to get a user document, but the user ID hasn't loaded yet, for instance.- This follows the same pattern as the
key
argument inuseSWR
. See the SWR docs for more. Functions are not currently supported for this argument.
query
(optional) Dictionary that accepts any of the following optional values:
listen = false
: if true, will set up a real-time listener that automatically updates.limit
: number that limits the number of documentswhere
: filter documents by certain conditions based on their fieldsorderBy
: sort documents by their fieldsstartAt
: number to start atendAt
: number to end atstartAfter
: number to start afterendBefore
: number to end beforeignoreFirestoreDocumentSnapshotField = true
: Iftrue
, docs returned indata
will not include the firestore__snapshot
field. Iffalse
, it will include a__snapshot
field. This lets you access the document snapshot, but makes the document not JSON serializable.
where
Can be an array, or an array of arrays.
Each array follows this outline: ['key', 'comparison-operator', 'value']
. This is pulled directly from Firestore's where pattern.
// get all users whose names are Fernando
useCollection('users', {
where: ['name', '==', 'Fernando'],
})
// get all users whose names are Fernando & who are hungry
useCollection('users', {
where: [
['name', '==', 'Fernando'],
['isHungry', '==', true],
],
})
// get all users whose friends array contains Fernando
useCollection('users', {
where: ['friends', 'array-contains', 'Fernando'],
})
orderBy
Can be a string, array, or an array of arrays.
Each array follows this outline: ['key', 'desc' | 'asc']
. This is pulled directly from Firestore's orderBy pattern.
// get users, ordered by name
useCollection('users', {
orderBy: 'name',
})
// get users, ordered by name in descending order
useCollection('users', {
orderBy: ['name', 'desc'],
})
// get users, ordered by name in descending order & hunger in ascending order
useCollection('users', {
orderBy: [
['name', 'desc'], //
['isHungry', 'asc'],
],
})
ignoreFirestoreDocumentSnapshotField
If true
, docs returned in data
will not include the firestore __snapshot
field. If false
, it will include a __snapshot
field. This lets you access the document snapshot, but makes the document not JSON serializable.
By default, it ignores the __snapshot
field. This makes it easier for newcomers to use JSON.stringify
without weird errors. You must explicitly set it to false
to use it.
// include the firestore document snapshots
const { data } = useCollection('users', {
ignoreFirestoreDocumentSnapshotField: false,
})
if (data) {
data.forEach(document => {
const path = document?.__snapshot.ref.path
})
}
The __snapshot
field is the exact snapshot returned by Firestore.
See Firestore's snapshot docs for more.
options
(optional) A dictionary with added options for the request. See the options available from SWR.
Return values
Returns a dictionary with the following values:
add(data)
: Extends the Firestore documentadd
function. Returns the added document ID(s).- It also updates the local cache using SWR's
mutate
. This will prove highly convenient over the regularadd
function provided by Firestore.
- It also updates the local cache using SWR's
The returned dictionary also includes the following from useSWR
:
data
: data for the given key resolved by fetcher (or undefined if not loaded)error
: error thrown by fetcher (or undefined)isValidating
: if there's a request or revalidation loadingmutate(data?, shouldRevalidate?)
: function to mutate the cached dataunsubscribe()
A function that, when called, unsubscribes the Firestore listener.- The function can be null, so make sure to check that it exists before calling it.
- 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.
useCollectionGroup(path, query, options)
Follows an identical API as useCollection
, except that it leverages Firestore's collection group query for merging subcollections with the same name.
To see how to use it, follow the instructions from useCollection
.
See the Firestore docs on collecttion groups to learn more.
set(path, data, SetOptions?)
Extends the firestore
document set
function.
- You can call this when you want to edit your document.
- It also updates the local cache using SWR's
mutate
. This will prove highly convenient over the regular Firestoreset
function. - The second argument is the same as the second argument for Firestore
set
.
This is useful if you want to set
a document in a component that isn't connected to the useDocument
hook.
update(path, data)
:
Extends the Firestore document update
function.
- It also updates the local cache using SWR's
mutate
. This will prove highly convenient over the regularset
function.
This is useful if you want to update
a document in a component that isn't connected to the useDocument
hook.
deleteDocument(path, ignoreLocalMutations = false)
Extends the Firestore document delete
function.
- It also updates the local cache using SWR's
mutate
by deleting your document from this query and all collection queries that have fetched this document. This will prove highly convenient over the regulardelete
function from Firestore. - Second argument is a boolean that defaults to false. If
true
, it will not update the local cache, and instead only send delete to Firestore.
revalidateDocument(path)
Refetch a document from Firestore, and update the local cache. Useful if you want to update a given document without calling the connected revalidate
function from use useDocument
hook.
- Only argument is the Firestore document path (ex:
users/Fernando
)
revalidateCollection(path)
Refetch a collection query from Firestore, and update the local cache. Useful if you want to update a given collection without calling the connected revalidate
function from use useCollection
hook.
- Only argument is the Firestore document path (ex:
users
) - Note Calling
revalidateCollection
will update all collection queries. If you're paginating data for a given collection, you probably won't want to use this function for that collection.
fuego
The current firebase instance used by this library. Exports the following fields:
db
: the current firestore collection instanceauth
: thefirebase.auth
variable.
import { fuego } from '@nandorojo/swr-firestore'
fuego.db.doc('users/Fernando').get()
fuego.auth().currentUser?.uid
getDocument(path, options?)
If you don't want to use useDocument
in a component, you can use this function outside of the React scope.
Arguments
path
required The unique document path for your Firestore document.options
ignoreFirestoreDocumentSnapshotField = true
. Iffalse
, it will return a__snapshot
field too.parseDates
: An array of string keys that correspond to dates in your document. Example.
Returns
A promise with the firestore doc and some useful fields. See the useDocument data
return type for more info.
getCollection(path, query?, options?)
If you don't want to use useCollection
in a component, you can use this function outside of the React scope.
Arguments
path
required The unique collection path for your Firestore collection.ignoreFirestoreDocumentSnapshotField = true
. Iffalse
, it will return a__snapshot
field too.parseDates
: An array of string keys that correspond to dates in your document. Example.
query
refer to the second argument ofuseCollection
.options
ignoreFirestoreDocumentSnapshotField = true
. Iffalse
, it will return a__snapshot
field too in each document.parseDates
: An array of string keys that correspond to dates in your documents. Example.
Features
TypeScript Support
Create a model for your typescript
types, and pass it as a generic to useDocument
or useCollection
.
useDocument
The data
item will include your TypeScript model (or null
), and will also include an id
string, an exists
boolean, and hasPendingWrites
boolean.
type User = {
name: string
}
const { data } = useDocument<User>('users/fernando')
if (data) {
const {
id, // string
name, // string
exists, // boolean
hasPendingWrites, // boolean
} = data
}
const id = data?.id // string | undefined
const name = data?.name // string | undefined
const exists = data?.exists // boolean | undefined
const hasPendingWrites = data?.hasPendingWrites // boolean | undefind
useCollection
The data
item will include your TypeScript model (or null
), and will also include an id
string.
type User = {
name: string
}
const { data } = useCollection<User>('users')
if (data) {
data.forEach(({ id, name }) => {
// ...
})
}
Shared global state between documents and collections
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.
So, what does this mean exactly?
Simply put, any documents pulled from a Firestore request will update the global cache.
To make it clear, let's look at an example.
Imagine you query a user
document from Firestore:
const { data } = useDocument('users/fernando')
And pretend that this document's data
returns the following:
{ "id": "fernando", "isHungry": false }
Remember that isHungry
is false
here ^
Now, let's say you query the users
collection anywhere else in your app:
const { data } = useCollection('users')
And pretend that this collection's data
returns the following:
[
{ "id": "fernando", "isHungry": true },
{
//...
}
]
Whoa, isHungry
is now true. But what happens to the original document query? Will we have stale data?
Answer: It will automatically re-render with the new data!
swr-firestore
uses document id
fields to sync any collection queries with existing document queries across your app.
That means that if you somehow fetch the same document twice, the latest version will update everywhere.
License
MIT