React Hooks + Firebase Firestore onSnapshot - Correct use of a firestore listener with react hooks

Viewed 2639

PROBLEM

Imagine that you have a screen with a database listener which is created in a useEffect, and the purpose of this listener is to increment a counter in your screen, just something like this:

(Only using the useEffect hook without dependencies)

function MyScreen(props) {
     const [magazinesCounter, setMagazinesCounter] = useState(0);

     const handleMagazinesChanges = (snapshot) => {
         const changes = snapshot.docChanges();

         let _magazinesCounter = magazinesCounter;
       
         changes.forEach((change) => {
            if (change.type === "added") {
               _magazinesCounter  += 1;
            }
            if (change.type === "removed") {
               _magazinesCounter  -= 1;
            }
         });
         
         setMagazinesCounter(_magazinesCounter);  
     };

     useEffect(() => {
         const query = props.db.collection("content")
                        .where("type", "==", "magazine");
         
         // Create the DB listener
         const unsuscribe = query.onSnapshot(handleMagazinesChanges, (err) => {});
         return () => {
            unsuscribe();
         }
     }, []);
}

As you can see here, this will not work because the handleMagazinesChanges which is used in the useEffect's listener is not re-created when the state updates...

So, I tried to fix that passing the magazinesCounter as dependency to the useEffect, like this:

(Using the useEffect hook with the modified state as dependency)

 useEffect(() => {
     // ... the same stuff
 }, [magazinesCounter]);

But with this, we will enter into an endless loop because the listener will be re-created, and

 if (change.type === "added") {
     _magazinesCounter  += 1;
 }
 ...
 setMagazinesCounter(_magazinesCounter);

will be executed again, and again...

Pd: Also, wrapping the handleMagazinesChanges function in a useCallback with the magazinesCounter as dependency, and then passing the function as dependency to the useEffect, will have the same effect...

So, how can I fix this situation? I know that if we use an auxiliar reference to the same data with useRef, we can perform this operation succesfully, avoiding the endless loop. But, is there any other better way to do that? I mean, without having the state + a reference to the most freshed data:

(useEffect without dependencies + useRef)

// This works good, but is there any other better solution to this problem?
function MyScreen(props) {
     const [magazinesCounter, setMagazinesCounter] = useState(0);

     const magazinesCounterRef = useRef(magazinesCounter);

     const handleMagazinesChanges = (snapshot) => {
         const changes = snapshot.docChanges();

         changes.forEach((change) => {
            if (change.type === "added") {
               magazinesCounterRef.current  += 1;
            }
            if (change.type === "removed") {
               magazinesCounterRef.current  -= 1;
            }
         });

         setMagazinesCounter(magazinesCounterRef.current);  
     };

     useEffect(() => {
         const query = props.db.collection("content")
                        .where("type", "==", "magazine");

         // Create the DB listener
         const unsuscribe = query.onSnapshot(handleMagazinesChanges, (err) => {});
         return () => {
            unsuscribe();
         }
     }, []);
}

Any ideas? Thank you.

Pd: Maybe this is the way to go, but I suppose there is a better way without creating auxiliar variables.

1 Answers

There are two recommendations I would have for achieving this:

  1. Move the callback inside of the useEffect to avoid re-creating the function each render
  2. Use a callback when setting state to get the current value (see the React Docs here) to avoid needing to re-create the callback when the state changes

Using these with your current code:

function MyScreen(props) {
     const [magazinesCounter, setMagazinesCounter] = useState(0);

     useEffect(() => {
         // Moved inside "useEffect" to avoid re-creating on render
         const handleMagazinesChanges = (snapshot) => {
             const changes = snapshot.docChanges();

             // Accumulate differences
             let difference  = 0;
             changes.forEach((change) => {
                if (change.type === "added") {
                   difference  += 1;
                }
                if (change.type === "removed") {
                   difference  -= 1;
                }
             });
             
             // Use the setState callback 
             setMagazinesCounter((currentMagazinesCounter) => currentMagazinesCounter + difference);  
         };

         const query = props.db.collection("content")
                        .where("type", "==", "magazine");
         
         // Create the DB listener
         const unsuscribe = query.onSnapshot(handleMagazinesChanges, 
  err => console.log(err));
         return () => {
            unsuscribe();
         }
     }, []);
}
Related