Home
> Firestore Query and Mutation Patterns
Get updates on future FREE course and blog posts!
Subscribe

Firestore Query and Mutation Patterns

15 min read

Jonathan Gamble

jdgamble555 on Sunday, April 21, 2024 (last modified on Sunday, April 21, 2024)

Since Firebase released the Web Modular API in Firebase 9, queries have been handled faster, bundles are smaller due to Tree-Shaking, and the API is growing with new features. However, the entire API is less intuitive and requires more practice than its predecessor.

TL;DR#

There are simple patterns to querying and modifying data effectively in Firestore. Always be aware of your environment, use error handling, and ALWAYS unsubscribe if you’re using onSnapshot.

Setup#

First, you must securely store your Firebase configuration information in a .env file. While this information is technically available to any novice hacker on the client, you still don’t want to upload it to GitHub or make it easily accessible. If you have secure Firestore Rules, there should be no worries.

	const firebase_config = JSON.parse(
    import.meta.env.PUBLIC_FIREBASE_CONFIG
);

Follow the Framework Guide for the best way to do this.

Initialize#

Next, you need to initialize Firebase in the client. If you’re only running one instance of Firebase, you only need to initialize Firebase when there is not an existing instance.

	if (!getApps().length) {
    initializeApp(firebase_config);
}

Firebase is a singleton class, meaning it can only be run once, but the SDK will still throw a warning if re-initialized. You can set a variable for the application if you like, but it is not strictly necessary. Firebase will get the default initialized app automatically.

	export const app = getApps().length
    ? getApp()
    : initializeApp(firebase_config);

If you run more than one Firebase instance in a single app, you have to pass the application instance to your functions.

Firebase Products#

You should return a variable for each product you want to use.

	export const db = getFirestore();
export const auth = getAuth();
export const storable = getStorage();
export const functions = getFunctions();

Remember to pass the app only if you need to.

	export const db = getFirestore(app);

Server and Client Environments#

Firebase is limited in server environments.

NodeJS#

When dealing with a Node environment, like AWS Serverless Functions, you can fetch unauthenticated data using the client SDK. You may have access to limited authentication functions, but you should not use them.

Session and Cookies#

You do not want to store a user’s authentication information on the server. You should use the proper cookie and session techniques and the client API. See SvelteKit Todo App with Firebase Admin.

Edge Functions#

You can fetch unauthenticated data in Edge Functions like Cloudflare and Vercel Edge (which uses Cloudflare) by importing Firestore Lite. This just uses the Firebase REST API under the hood but does not work with any authentication.

	import { doc, getDoc, getFirestore } from "firebase/firestore/lite";

However, you will not have access to any session or cookie functions. You must call a Firebase Callable Function to get authenticated data and handle sessions and cookies. You could equally call an API in any NodeJS environment. Either way, your authenticated data will probably be retrieved slower. The only other method is to directly query the REST API, which will require a lot of boilerplate. This hopefully changes in the future. See Firebase Wish List.

Tradeoffs.

Importing#

Be careful importing functions in environments that do not support them. You do not need to create a shared hook for Firebase on the client, but it is a good idea on the server.

Queries#

This is how you return documents with promises. For all examples I will be using the posts collection.

Get a Document#

	const snap = await getDoc(
  doc(db, 'posts/some-document-id')
);

Get All Documents#

	const snaps = await getDocs(
    collection(db, 'posts')
);

Get Some Documents#

	const snaps = await getDocs(
  query(
     collection(db, 'posts'),
     limit(3)
  )        
);

Options#

	// filters
where(field, comparison, value)
orderBy(field, "desc|asc")
limit(1)
or(query)

// cursors using field or docSnap
startAt()
startAFter()
endAt()
endBefore()

// comparisons
"<", "<=", "==", ">", ">=", "!="

// array comparisons
"array-contains", "array-contains-any", "in", "not-in"

Snapshot#

When you get document data, you get the snapshot data. You then need to decide what to do with it. You may want to use Data Converters as well. You should check for existence and combine the document ID with the data.

	const snapToData = <T>(
    snap: DocumentSnapshot<DocumentData, DocumentData>
) => {

    if (!snap.exists()) {
        throw 'Document DNE!';
    }

    return {
        data: snap.data() as T,
        id: snap.id
    };
}

You would call it after getting the snapshot.

	const snap = await getDoc(
    doc(db, 'posts', 'post-document-id')
);

const data = snapToData<PostType>(snap);

Or for multiple documents.

	const snaps = await getDocs(
    collection(db, 'posts')
);

if (snap.empty) {
    throw 'No Documents';
}

const data = snaps.docs.map(snap => snapToData<PostType>(snap))

Realtime#

To make any query real-time, use onSnapshot instead of getDoc or getDocs. This subscribes to changes to the document snapshot.

	const unsubscribe = onSnapshot(
  doc(db, 'posts', postID),
  (doc) => {
    // handle document snapshot(s)
  }
);  

Convert to Observable#

It is often more useful to convert the onSnapshot to an RxJS Observable.

	const postSnap = new Observable<DocumentSnapshot<PostType>>(
    (subscriber) => onSnapshot(
        doc(db, 'posts', 'x1s3') as any,
        subscriber
    ));

You could also use a data converter here for better typing. Here is the query version.

	const postSnaps = new Observable<QuerySnapshot<PostType>>(
  (subscriber) => onSnapshot(
      collection(db, 'posts').withConverter<PostType>(dataConverter),
      subscriber
  ));

Unsubscribe#

⚠️ ALWAYS unsubscribe. No excuses, or you will have memory leaks in your app. This is usually handled in an onDestroy function.

	onDestroy(unsubscribe);

Mutations#

You also need to CRUD documents.

Add a Document#

	await addDoc(
    collection(db, 'posts'), {
        title: 'new post',
        ...
    }
);

OR

	// create a new document ID
const newID = collection(db, 'posts').id;

await setDoc(
    doc(db, 'posts', newID), {
        title: 'new post',
        ...
    }
);

Update a Document#

	await updateDoc(
  doc(db, 'posts', postID), {
    title: 'new post title',
    ...
  }
);

Upsert a Document#

This updates the document or creates it if it doesn’t exist.

	await setDoc(
  doc(db, 'posts', postID), {
    title: 'new post title,
    createdAt: '...',
  },
  { merge: true }
);

Update an Array#

For more on arrays see Firestore Many-to-Many: Arrays.

	await updateDoc(
  doc(db, 'posts', postID), {
    arrayname: arrayUnion('new value') // or arrayRemove('old value')...
  }
);

📝 You could equally use setDoc with merge set to true for updating arrays or incrementing below.

Increment a Counter#

	await updateDoc(
  doc(db, 'posts', postID), {
    counter: increment(1), // or increment(-1) for decrement
    ...
  }
);

Delete a Document#

	await deleteDoc(
  doc(db, 'posts', postID)
);

Transactions#

You want to use transactions to get and update data simultaneously. You can’t modify the data until the transaction is complete. If one mutation fails, they all do.

	 const txData = await runTransaction(db, async (transaction) => {
 
  // get some data
  const docRef = doc(db, 'posts', postID);
  
  const docSnap = await transaction.get(docRef);
  
  if (!docSnap.exists()) {
    throw 'Document DNE';
  }
    
  const data =  { 
    ...doc.data(),
    id: doc.id
  };
  
  // modify some data, great for increments  
  const new_data = { ... };

  // await not necessary due to tx
  transaction.update(docRef, new_data);

  // return new data
  return { ... };
});

See Firestore Cloud Functions Counter for an example.

Batches#

	// batch writes
const batch = writeBatch(db);
const docRef = doc(db, 'posts', postID);
const docRef2 = doc(db, 'posts', postID2);
const docRef3 = doc(db, 'posts', postID3);

batch.set(docRef, { ... });
batch.update(docRef2, { ... });
batch.delete(docRef3);

await batch.commit();

You can securely modify multiple documents at once, or they all fail. See Secure Batch Count for an example.

Error Handling#

The errors occur in promises.

Catch Method#

This will only get called whenever the promise resolves or rejects.

	deleteDoc(
  doc(db, 'posts', postID)
).catch((e) => {
    if (e instanceof FirebaseError) {
        console.error(e.message);
    }
});

Try / Catch#

You must have await here in an async function for this to work.

	try {
    
    // any firebase promise
    await deleteDoc(
      doc(db, 'posts', postID)
    );
    
} catch (e) {
    if (e instanceof FirebaseError) {
        console.error(e.code);
    }
}

Error Pattern#

When creating larger apps, I like to return an error in my function and handle it there.

	const getData = (id: string) => {

  try {
  
    // get document
    const snap = await getDoc(
      doc(db, 'posts', id)
    );
    
    if (!snap.exists()) {
      throw new Error('Document DNE!');
    }
    const data = snap.data();
    
    // return data
    return {
      data: {
        ...data,
        id: snap.id
      }
    };
    
  } catch (_e) {
  
    // get typing correct, or check for instanceof error class
      const e = _e as Error;
    return {
        error: e.message
    };
  }

Then you could call the function easily. This will make error handling easier throughout your code.

	const { error, data } = await getPost('post-id-here');

I admit I need to handle errors more in my example apps 😎

J


Related Posts

© 2024 Code.Build