2

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).

Mel
  • 2,481
  • 26
  • 113
  • 273
  • 1
    Two things to check: 1.) Do you may have unexpected rerenders? The snapshot looks fine, but how often does it get run? Easy to see with console.log in the `useEffect` 2.) What do you see in the network tab? How often are requests send by Firestore? – Bennett Dams Jul 21 '20 at 22:11
  • 1
    they are being fired constantly - I thought the update was only supposed to occur when the data was changed. I'm only using the form in one place, I ran the local host for 30 seconds and the console showed repeat logs of the data (too many to count) – Mel Jul 21 '20 at 22:38
  • Incidentally, another 7k reads (from a total of 12 firestore records) accrued in the 1 minute that I used to run the console and check on the points your raised. The console was open - but these numbers are nowhere near what i expected. In fact, I expected no reads given that the data didn't change, and the dependency in the hook would use the existing state. – Mel Jul 21 '20 at 23:56
  • 1
    Maybe the whole component (`CheckboxesTags`) gets rerendered by the parent a lot, therefore the `useEffect` is run again and again, even if the hook dependency doesn't change. In other words: Check if the parent triggers a rerender of your component, not the `useEffect` inside the component. – Bennett Dams Jul 22 '20 at 09:15
  • How can I check that? It's a form embedded in a page that does nothing other than render the form. In the space of the minute that I ran the local host, I did nothing aside from click the select menu. – Mel Jul 22 '20 at 09:42
  • @BennettDams is correct, and I believe even selecting the Select Menu would also cause the `useEffect` to be called. Try putting a console.log, or a debugger statement, hit the select menu. Also, have we considered putting a if statement `if (!orgList.length)`, or do we need it because we want to watch for reactive updates to firestore? – hrgui Jul 26 '20 at 07:11
  • @hrgui - what is he correct about? I'm trying to investigate his suggestions. If the useEffect is called enough times to make 50k reads from 12 attributes in the database, what is the rule that is being applied? If there is no change to the data, how are the 3 statements in my post applied to count reads. Where should I put the console.log? The place I've shown it in the post above, returns a long list of endlessly repeated logs, with the same data. – Mel Jul 26 '20 at 07:38
  • @Mel, I would put a console.log statement at the beginning of the effect, and also when we clean up the effect. I was **incorrect** about `I believe even selecting the Select Menu would also cause the useEffect to be called`. @BennettDams is correct about checking to see if the parent is causing the re-render, if there is a parent. In addition, leaving the firestore web console does create unexpected reads: https://stackoverflow.com/questions/56434008/firestore-unexpected-reads, so best to leave that closed. – hrgui Jul 26 '20 at 14:44
  • Actually, on 2nd thought - I believe there's another error, which I will put in an as an attempted answer - please take a look. – hrgui Jul 26 '20 at 14:46

4 Answers4

3
  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
hrgui
  • 590
  • 2
  • 5
  • How is it a new array from firestore? The firestore data doesn't change (the records in firestore are unchanged) and the orgList hasn't been saved to firestore (at least from this code). Thanks for creating the stackblitz. It does replicate the problem I've encountered. Any ideas on how to solve for this? – Mel Jul 26 '20 at 21:29
  • .map always creates a new array, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map – hrgui Jul 27 '20 at 04:17
  • Added suggestion - putting a if condtion that checks for the orgList.length will stop consecutive subscribes – hrgui Jul 27 '20 at 04:22
  • Hmmm - that's really helpful as a suggestion. Thank you for thinking of it. It just doesn't make any sense. It's mapping over the data read from firestore. There are no changes to that data. The firestore docs suggest that the read only happens when the data is updated. If the orgList were originally 2 items and then one was removed, the array would need to update but the if condition would block it from running. I'll think on this more tonight. Thank you again. – Mel Jul 27 '20 at 06:48
  • Also - if the data is only needed once, wouldn't .get be a better way to ask for it (rather than onSnapshot), which the firebase docs appear to suggest should only run when the data in firestore changes? – Mel Jul 27 '20 at 07:37
  • Yes, .get() would be better, as you don't have to unsubscribe - because its a promise. You can then use react-async, or react-query, or any async util to help with this. – hrgui Jul 28 '20 at 04:50
  • Ok - but then is it not possible to use snapshot listeners with react hooks? There must be a way to use firestore snapshots in a hook, without this repeated read to the database (when there are no changes to the data). – Mel Jul 28 '20 at 04:55
  • It is possible, but it has to violate some rule of React hooks. Remove the dependency orgList, and it'll only call it once. See suggestion 2. – hrgui Jul 28 '20 at 05:07
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/218728/discussion-between-hrgui-and-mel). – hrgui Jul 28 '20 at 05:10
  • 1) useEffect could have its dependencies be [setOrgList] and NOT [orgList]. 2) If this is for React (web/browser), have you enabled persistence to be sure the query is hitting the local cache instead of always going to the server? By default, persistence is enabled for iOS and Android but disabled for web/browser. – Greg Fenton Jul 28 '20 at 15:56
  • Thank you for all your help with this. – Mel Jul 29 '20 at 01:49
1

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

Gustavo Hoirisch
  • 1,637
  • 12
  • 19
  • note also that you have a bug with the `setLoading` call as it is called outside the promise chain – Gustavo Hoirisch Jul 28 '20 at 05:12
  • Thank you for all your help with this. I have to wait until tomorrow to add another bounty so that I can give you points for this as well. Thanks again for the help. – Mel Jul 29 '20 at 01:49
0

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.

eyoto
  • 1
  • 3
  • im trying to figure out why the unsubscribe, the dependency in the hook and the seeming contradiction between the snapshot description in the firestore docs and the observed behaviour here are resulting in multiple reads – Mel Jul 23 '20 at 21:41
0

For anyone else looking to learn, I just found this blog post which also helps understand the dependency array: https://maxrozen.com/learn-useeffect-dependency-array-react-hooks/

Mel
  • 2,481
  • 26
  • 113
  • 273