React hooks with firestore data - 1.1k reads in 30 seconds (from 2 collections with 3 documents with 2 attributes)

Viewed 1574

I'm trying to understand how the Cloud Firestore read quota works. I have read this post and the response to it. My console was open, but I cannot construe how a single collection with 3 documents, each having 2 attributes constitutes a "busy console".

I'm struggling to make sense of the documentation.

I have two collections in firestore. Each one has 3 documents. Each document has 2 attributes. I'm using those attributes to populate options in the Autocomplete select menu from Material UI.

In localhost, I have been running the form to test getting those attributes into an Autocomplete select menu, using a single snapshot. In 30 seconds, these two form items produced 1.1k reads in firestore.

I thought:

  1. snapshot only updated when data in firestore changed.

  2. that by using unsubscribe, then the hook would stop listening for changes in firestore.

  3. efficiency of the firestore read was improved by adding the state of the list as a dependency to the hook (the orgList at the end of the useEffect block): https://medium.com/javascript-in-plain-english/firebase-firestore-database-realtime-updates-with-react-hooks-useeffect-346c1e154219.

Can anyone see how 1.1k reads are being generated by running this form with 2 input items only (there are no other firestore calls in the whole app at the moment).

import React, { useState, useEffect } from 'react';
import Checkbox from '@material-ui/core/Checkbox';
import TextField from '@material-ui/core/TextField';
import Autocomplete from '@material-ui/lab/Autocomplete';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import firebase from "../../../../../firebase";

const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;

export default function CheckboxesTags() {
  const [orgList, setOrgList] = useState([]);
  const [selectedOrgList, setSelectedOrgList] = useState();
  const [loading, setLoading ] = useState(true);
  const [ error, setError ] = useState(false);

  useEffect(() => {
    // if (doc.exists) {
      const unsubscribe = firebase
        .firestore()
        .collection("organisations")
        .onSnapshot((snapshot) => {
          const orgList = snapshot.docs.map((doc) => ({
            id: doc.id,
            shortName: doc.data().shortName,
            location: doc.data().location
          }));
          setOrgList(orgList);
          console.log("orglist", orgList)
        }, () => {
          setError(true)
        });
        setLoading(false);
        return() => unsubscribe();
     
    
  }, [orgList]);


   

  return (
      <div>
      
        <Autocomplete
        multiple
        id="orgList options"
        options={orgList}
        disableCloseOnSelect
        getOptionLabel={(option) => option.shortName}
        renderOption={(orgList, { selected }) => (
            <React.Fragment>
            <Checkbox
                icon={icon}
                checkedIcon={checkedIcon}
                style={{ marginRight: 8 }}
                checked={selected}
            />
            {orgList.shortName}  <span style={{marginRight: "4px", marginLeft: "4px"}}>-</span>
            {orgList.location}
            </React.Fragment>
        )}
        style={{ width: 500 }}
        renderInput={(params) => (
            <TextField {...params} 
            variant="outlined" 
            label="Select Organisation" 
            placeholder="Acme Inc." 
          />
        )}
        />
    </div>
  );
}

The other form is exactly like this, but instead of orgList - it has userList. Otherwise - it's all the same (so: 2 collections, 3 documents in each collection, 2 attributes in each document).

4 Answers
  const [orgList, setOrgList] = useState([]);
  const [selectedOrgList, setSelectedOrgList] = useState();
  const [loading, setLoading ] = useState(true);
  const [ error, setError ] = useState(false);

  useEffect(() => {
    // if (doc.exists) {
      const unsubscribe = firebase
        .firestore()
        .collection("organisations")
        .onSnapshot((snapshot) => {
          const orgList = snapshot.docs.map((doc) => ({
            id: doc.id,
            shortName: doc.data().shortName,
            location: doc.data().location
          }));
          setOrgList(orgList);
          console.log("orglist", orgList)
        }, () => {
          setError(true)
        });
        setLoading(false);
        return() => unsubscribe();
     
    
  }, [orgList]);

From my understanding of this, we are telling React, run this effect if orgList changes. The effect does the following:

  1. Make the call to Firestore.
  2. Map... <- this contributes to the problem, because we're creating a new array.
  3. setOrgList(orgList) <- this is where the problem lies

Now that orgList has changed, React has to rerun the effect. I created a similarish stackblitz (from material-ui's homepage) that can cause this problem. See https://stackblitz.com/edit/evsxm2?file=demo.js. See the console and notice it just runs all the time.

Possible ways to solve for this

If we only need the data once and only once...

Suggestion 1: put a if condition in the beginning of useEffect

  const [orgList, setOrgList] = useState([]);
  const [selectedOrgList, setSelectedOrgList] = useState();
  const [loading, setLoading ] = useState(true);
  const [ error, setError ] = useState(false);

  useEffect(() => {
      if (orgList.length > 0) {
         return; // we already have data, so no need to run this again
      }

      const unsubscribe = firebase
        .firestore()
        .collection("organisations")
        .onSnapshot((snapshot) => {
          const orgList = snapshot.docs.map((doc) => ({
            id: doc.id,
            shortName: doc.data().shortName,
            location: doc.data().location
          }));
          setOrgList(orgList);
          console.log("orglist", orgList)
        }, () => {
          setError(true)
        });
        setLoading(false);
        return() => unsubscribe();
     
    
  }, [orgList]);

Suggestion 2 If we really need to listen on realtime changes...? (I haven't tested this)

  const [orgList, setOrgList] = useState([]);
  const [selectedOrgList, setSelectedOrgList] = useState();
  const [loading, setLoading ] = useState(true);
  const [ error, setError ] = useState(false);

  useEffect(() => {
      const unsubscribe = firebase
        .firestore()
        .collection("organisations")
        .onSnapshot((snapshot) => {
          const orgList = snapshot.docs.map((doc) => ({
            id: doc.id,
            shortName: doc.data().shortName,
            location: doc.data().location
          }));
          setOrgList(orgList);
        }, () => {
          setError(true)
        });
        setLoading(false);
        return() => unsubscribe();
     
    
  }, []); // we don't depend on orgList because we always overwrite it whenever there's a snapshot change

orgList should not be declared as a dependency in the useEffect hook, what you really want is setOrgList.

I believe you are triggering an infinite loop here as the hook re-triggers every time orgList changes and it is always "updated" inside the hook itself, re-triggering it. However it is never used inside the hook and it looks like that's really not what you are after. If you want to set up the snapshot listener only on "mount" then simply use an empty dependency list or look into memoization strategies. What you likely want is:

  useEffect(() => {
      const unsubscribe = firebase
        .firestore()
        .collection("organisations")
        .onSnapshot((snapshot) => {
          const orgList = snapshot.docs.map((doc) => ({
            id: doc.id,
            shortName: doc.data().shortName,
            location: doc.data().location
          }));
          setOrgList(orgList);
          setLoading(false);
        }, () => {
          setError(true)
          setLoading(false);
        });

        return () => unsubscribe();
  }, []); // <----- empty dependency means it only runs on first mount

Edit:

The likely confusion is that you think if the data looks the same inside orgList then React should know to not retrigger however useEffect is not as smart as you might think so you have to do some work yorself to help it out. As orgList is an object, it is really a reference and this reference is being updated repeatedly. Some potential clarification on te by value vs by reference here: JavaScript by reference vs. by value

A solution that worked for many is to use cache rather than reading from Firestore constantly.

For example, directly from the Firebase documentation

var getOptions = {
    source: 'cache'
};

// Get a document, forcing the SDK to fetch from the offline cache.
docRef.get(getOptions).then(function(doc) {
    // Document was found in the cache. If no cached document exists,
    // an error will be returned to the 'catch' block below.
    console.log("Cached document data:", doc.data());
}).catch(function(error) {
    console.log("Error getting cached document:", error);
});

This is also very informative and has a code example (though in Java for Android) that I find very useful to understanding how to reduce the amount of reads to Firestore.

Related