The Essential Firestore Recipes for Every Firestore App

Whether you're new to Firebase or a seasoned pro, you've probably looked at the wonderful documentation and scratched your head, wondering what to do next. We've all been there! I'm going to hopefully save you a little time and pass on the 10 essential patterns that you will use over and over in your app.

Note: in keeping with the latest tech, we're going to use Web v9 which is written in JavaScript.

Best Practices

When organizing your project for Firebase, there are a few best practices that are essential recipes for managing a production level app.

A config file

Firebase gives you the option to call for the current app config whenever and wherever you want by using their new getX functions. For example, to get a Firestore instance, you can use const db = getFirestore() and this will get the instance associated with the default app.

Instead of using that line every time, it's better practice to create a firebase.js file that looks a little something like this:

import { initializeApp, getApps, getApp } from 'firebase/app'
import { getFirestore } from 'firebase/firestore'
import { getAuth } from 'firebase/auth'
import { getAnalytics } from 'firebase/analytics'

const firebaseConfig = { ... }

const app = (!getApps().length) ? initializeApp(firebaseConfig) : getApp()

const db = getFirestore(app)
const auth = getAuth(app)
const analytics = getAnalytics(app)

export { db, auth, analytics }

The new documentation claims you don't need to initialize the default app like I do when creating the app variable, you can simply get the default instances without passing an app parameter to each getX feature, but old habits die hard.

That's it! No need to get the instance each time you reference the database, auth, analytics, etc. because you can just import those instances from your top level firebase.js file.

Environment variables for collection names

This practice might be the most important in this entire article so pay attention! In the documentation every collection reference is a hard coded string, so it's tempting to reference your collection names that way. However, when your app is in production and you want to add some dummy users to the users collection, you're stuck going back through and changing all the collection names to not impact your production data.

So, while that all may seem obvious, please assign a variable, or object, or whatever you need to do to dynamically change your collection names from 'users' to 'users-dev' to 'users-staging'. If you're using Next.js, those can be environment variables, or in Angular those references can be in your environment.js and environment.production.js files. Either way, implement a dynamic approach.

Organize like API calls into a single file

Hopefully this is obvious, but I still see a lot of developers placing the logic to retrieve data from Firestore directly into their useEffect hooks (React) or ngOnInit functions (Angular). Firebase follows the async / await function pattern for retrieving data, and your app should too. Here's what I mean:

// users.js
import { ... } from 'firestore/...'
import { db } from './firestoreConfigFile'

const userRef = collection(db, USER_ENV_REF_NAME)

const getUserById = (userID) => { return new Promise ... }

export { getUserById }

When you want to retrieve a user by their ID, you simply import getUserByID and all the logic is nicely tucked away in your API and completely reusable throughout the remainder of your app.

For all data retrieval operations, your API (application program interface) for communicating between the UI and the database should be all in one place. Then, when you need to retrieve your data from the UI, your only imports are for the functions themselves. There is no need to import everything from Firestore in your UI and clutter the UI business logic with the data retrieval. The senior devs will see that you took the time to organize and modularize your code, give you a gold star, and invite you to lunch to laugh about all the rookie mistakes they made as junior devs, like storing all of the business logic for the API in the UI.

Bonus points if the top level folder in your repo is labeled api. If you're using Next.js, this is done for you :)

Database commands

These code samples for retrieving data are patterns you will use over and over in your app, and reflect best practices for working with Firestore.

Write to a doc with a generated ID

Practically speaking, addDoc and setDoc accomplish the same goal of writing a document to the database. If you are specifying your own document IDs, then with setDoc you can just pass the ID as a data parameter on initial creation. When you're working with an autogenerated ID, this pattern is essential:

const db = getFirestore()
const data = { ... }

const usersRef = collection(db, 'users')
const docRef = await addDoc(usersRef, data)
await updateDoc(doc(db, 'users', docRef.id), { id: docRef.id })

Let's break down what's happening here.

First, you create a reference to the collection, where db is the Firestore instance related to this app and 'users' is the name of the collection. Note that getFirestore() provides the default app instance, but can take a configuration parameter if you're using more than one Firestore instance in your app.

Next, pass that collection reference in to an asynchronously (the await keyword) called addDoc function with the data for the document. This returns a document reference with contains the id for the newly created document.

Finally, asynchronously call updateDoc to update the newly created document with it's own ID. It takes the doc function as the first parameter which takes as its parameters the database reference, collection name, and document ID then updateDoc takes data as the second parameter.

Alternatively, if you are generating your own IDs, you can simply use setDoc to provide the ID field to the initially created doc and skip the add / update pattern altogether.

Use a Converter

The documentation for how to use a converter is pretty good, so I won't reinvent the wheel, but using a converter in Firestore will save a lot of headache, especially when working with timestamps.

When data is sent and returned from Firestore, it takes a very generic structure. If you're coding in TypeScript, it's the DocumentData class which essentially looks like

type DocumentData {
  [key: string]: any
}

There are two main benefits to a converter:

  1. Give the data a recognizable type
  2. Manipulate the data on upload and return

A converter converts the data from a highly generic type into a class that you know and love.

The manipulation part is especially important, for example, when a timestamp field comes back from Firestore, it's in this unique object with nanoseconds and seconds fields. There is a function toDate() you can call on that object to convert it to a date, but if you are working with the data directly then you need to call this function every time you want to convert it!

With a converter, specify what data you want to send to Firestore and how you want to get it back. The below example describes data related to a round of golf.

const roundConverter = {
  toFirestore: (round: DocumentData) => {
    return {
      id: round.id,
      course: round.course,
      date: round.date,
    }
  },
  fromFirestore: (snapshot: DocumentSnapshot, options: SnapshotOptions) => {
    const data = snapshot.data(options);
    return new Round(data?.id, data?.course, data?.date.toDate());
  }
};

When I send the data to Firestore, I'm using the DocumentData type (this is TypeScript code to show what's going in and out) with no manipulations before it goes out. When the data comes back, I want to create a new Round class but I already want the Firestore date object converted to a useful Date object when it comes back to the app. Therefore, when I pass the date parameter to my Round class, I call the conversion function and all my rounds data coming through will have a Date object instead of the custom Firestore timestamp object.

Putting it altogether, we retrieve our data like this:

const docSnap = await getDoc(doc(db, 'rounds', roundId)
  .withConverter(roundConverter))
if (docSnap.exists()) {
   const round = docSnap.data()
   resolve(round)
} else {
   reject({ errorMessage: 'Round now longer exists' })
}

The powerful query & onSnapshot combo

The Firestore 'query' function is for trimming down and organizing your data, and combined with onSnapshot creates an essential combination. The onSnapshot function creates a subscription to the data, and updates every time that collection changes. This is immensely useful for an in app chat feature, or notifications.

It's usage is fairly simple:

  const q = query(chatFeedsCollectionRef, orderBy('createdAt', 'desc'))
  await onSnapshot(q, { includeMetadataChanges: true }, (snap) => {
    callbackFn(snap.docs.map(doc => doc.data()))
  }, (err) => {
    // handle onError
  }, () => {
    // handle onComplete
  })

We structure our query to organize the data from the collection descending based on when it was created, so that we see the newest items first. Then, we configure our snapshot to return changes including if there are metadata changes (an optional options parameter), then the snapshot is passed through to our callback function which will update with the data. We're taking that snapshot of data, and extracting the data by calling the data() function (otherwise we just get a reference object describing the document). Mapping the actual data from the documents means we pass to our callback function just the neat and tidy data in our database.

Wrapping it up

We've reviewed just a few best practices and common Firestore patterns, but there are many more implementations of common patterns we didn't cover in this article that are well documented in the documentation like deleting data. Now, go write some amazing code with Firebase!