1

I have an array of data object to be rendered. and this array of data is populated by Firestore onSnapshot function which i have declared in the React hook: useEffect. The idea is that the dom should get updated when new data is added to firestore, and should be modified when data is modified from the firestore db. adding new data works fine, but the problem occurs when the data is modified. here is my code below:

import React, {useState, useEffect} from 'react'

...

const DocList = ({firebase}) => {
    const [docList, setDocList] = useState([]);
useEffect(() => {
        const unSubListener = firebase.wxDocs()
        .orderBy("TimeStamp", "asc")
        .onSnapshot({ 
                includeMetadataChanges: true 
            }, docsSnap => {
            docsSnap.docChanges()
            .forEach(docSnap => {
                let source = docSnap.doc.metadata.fromCache ? 'local cache' : 'server';
                if (docSnap.type === 'added') {
                    setDocList(docList => [{
                        source: source,
                        id: docSnap.doc.id,
                        ...docSnap.doc.data()
                    }, ...docList]);
                    console.log('document added: ', docSnap.doc.data());
                } // this works fine
                if (docSnap.type === 'modified') {
                    console.log('try docList from Lists: ', docList); //this is where the problem is, this returns empty array, i don't know why
                    console.log('document modified: ', docSnap.doc.data()); //modified data returned
                }
        })
        })
        return () => {
            unSubListener();
        }
    }, []);

apparently, i know the way i declared the useEffect with empty deps array is to make it run once, if i should include docList in the deps array the whole effect starts to run infinitely.

please, any way around it?

Capt Weiss
  • 61
  • 5
  • You can do `setDocList(current=>current.map(item=>...` – HMR Jun 18 '20 at 15:57
  • I know this isn't what you want to hear, but I would probably suggest using `useReducer` https://reactjs.org/docs/hooks-reference.html#usereducer, rather than `useState` for tracking an array of objects. It can make updating easier to track. As for your bug, I don't think `setDocList`, even with the the prevState function, is guaranteed to be up to date by the time you get into that if statement. Have you considered just adding all docs (`type === 'added' || type === 'modified'`), and doing your sorting/filtering outside of useEffect? – Brett East Jun 18 '20 at 16:03
  • Seems like you need to use the setLoading method in order to manage the response from Firebase, I found this article, I hope will help https://medium.com/javascript-in-plain-english/firebase-firestore-database-realtime-updates-with-react-hooks-useeffect-346c1e154219 – Harif Velarde Jun 18 '20 at 18:59
  • @HMR thanks, but when I tried this, the value of current is empty array, so there will be nothing to Map through. – Capt Weiss Jun 19 '20 at 03:07
  • @BrettEast i will try this, hopefully it should solve the problem – Capt Weiss Jun 19 '20 at 03:18

2 Answers2

0

Based on @BrettEast suggestion;

I know this isn't what you want to hear, but I would probably suggest using useReducer reactjs.org/docs/hooks-reference.html#usereducer, rather than useState for tracking an array of objects. It can make updating easier to track. As for your bug, I don't think setDocList, even with the the prevState function, is guaranteed to be up to date by the time you get into that if statement.

I use useReducer instead of useState and here is the working code:

import React, {useReducer, useEffect} from 'react'
import { withAuthorization } from '../../Session'
import DocDetailsCard from './Doc';

const initialState = [];

/**
 * reducer declaration for useReducer
 * @param {[*]} state the current use reducer state
 * @param {{payload:*,type:'add'|'modify'|'remove'}} action defines the function to be performed and the data needed to execute such function in order to modify the state variable
 */
const reducer = (state, action) => {
    switch (action.type) {
        case 'add':
            return [action.payload, ...state]

        case 'modify':
            const modIdx = state.findIndex((doc, idx) => {
                if (doc.id === action.payload.id) {
                    console.log(`modified data found in idx: ${idx}, id: ${doc.id}`);
                    return true;
                }
                return false;
            })
            let newModState = state;
            newModState.splice(modIdx,1,action.payload);
            return [...newModState]

        case 'remove':
            const rmIdx = state.findIndex((doc, idx) => {
                if (doc.id === action.payload.id) {
                    console.log(`data removed from idx: ${idx}, id: ${doc.id}, fullData: `,doc);
                    return true;
                }
                return false;
            })
            let newRmState = state;
            newRmState.splice(rmIdx,1);
            return [...newRmState]

        default:
            return [...state]
    }
}

const DocList = ({firebase}) => {
    const [state, dispatch] = useReducer(reducer, initialState)

    useEffect(() => {
        const unSubListener = firebase.wxDocs()
        .orderBy("TimeStamp", "asc")
        .onSnapshot({ 
                includeMetadataChanges: true 
            }, docsSnap => {
            docsSnap.docChanges()
            .forEach(docSnap => {
                let source = docSnap.doc.metadata.fromCache ? 'local cache' : 'server';
                if (docSnap.type === 'added') {
                    dispatch({type:'add', payload:{
                        source: source,
                        id: docSnap.doc.id,
                        ...docSnap.doc.data()
                    }})
                }
                if (docSnap.type === 'modified') {
                    dispatch({type:'modify',payload:{
                        source: source,
                        id: docSnap.doc.id,
                        ...docSnap.doc.data()
                    }})
                }
                if (docSnap.type === 'removed'){
                    dispatch({type:'remove',payload:{
                        source: source,
                        id: docSnap.doc.id,
                        ...docSnap.doc.data()
                    }})
                }
        })
        })
        return () => {
            unSubListener();
        }
    }, [firebase]);

    return (
        <div >
            {
                state.map(eachDoc => (
                    <DocDetailsCard key={eachDoc.id} details={eachDoc} />
                ))
            }
        </div>
    )
}

const condition = authUser => !!authUser ;
export default React.memo(withAuthorization(condition)(DocList));

also according to @HMR, using the setState callback function: here is the updated code which also worked if you're to use useState().

import React, { useState, useEffect} from 'react'
import { withAuthorization } from '../../Session'
import DocDetailsCard from './Doc';

const DocList = ({firebase}) => {
    const [docList, setDocList ] = useState([]);
    const classes = useStyles();

    useEffect(() => {
        const unSubListener = firebase.wxDocs()
        .orderBy("TimeStamp", "asc")
        .onSnapshot({ 
                includeMetadataChanges: true 
            }, docsSnap => {
            docsSnap.docChanges()
            .forEach(docSnap => {
                let source = docSnap.doc.metadata.fromCache ? 'local cache' : 'server';
                if (docSnap.type === 'added') {
                    setDocList(current => [{
                        source: source,
                        id: docSnap.doc.id,
                        ...docSnap.doc.data()
                    }, ...current]);
                    console.log('document added: ', docSnap.doc.data());
                }
                if (docSnap.type === 'modified') {
                    setDocList(current => current.map(item => item.id === docSnap.doc.id ? {
                            source: source,
                            id: docSnap.doc.id,
                            ...docSnap.doc.data()} : item )
                    )
                }
                if (docSnap.type === 'removed'){
                    setDocList(current => {
                        const rmIdx = current.findIndex((doc, idx) => {
                            if (doc.id === docSnap.doc.id) {
                                return true;
                            }
                            return false;
                        })
                        let newRmState = current;
                        newRmState.splice(rmIdx, 1);
                        return [...newRmState]
                    })
                }
        })
        })
        return () => {
            unSubListener();
        }
    }, [firebase]);

    return (
        <div >
            {
                docList.map(eachDoc => (
                    <DocDetailsCard key={eachDoc.id} details={eachDoc} />
                ))
            }
        </div>
    )
}

const condition = authUser => !!authUser ;
export default React.memo(withAuthorization(condition)(DocList));

Thanks hope this help whoever is experiencing similar problem.

Capt Weiss
  • 61
  • 5
0

As commented, you could have used setDocList(current=>current.map(item=>..., here is working example with fake firebase:

const firebase = (() => {
  const createId = ((id) => () => ++id)(0);
  let data = [];
  let listeners = [];
  const dispatch = (event) =>
    listeners.forEach((listener) => listener(event));
  return {
    listen: (fn) => {
      listeners.push(fn);
      return () => {
        listeners = listeners.filter((l) => l !== fn);
      };
    },
    add: (item) => {
      const newItem = { ...item, id: createId() };
      data = [...data, newItem];
      dispatch({ type: 'add', doc: newItem });
    },
    edit: (id) => {
      data = data.map((d) =>
        d.id === id ? { ...d, count: d.count + 1 } : d
      );
      dispatch({
        type: 'edit',
        doc: data.find((d) => d.id === id),
      });
    },
  };
})();
const Counter = React.memo(function Counter({ up, item }) {
  return (
    <button onClick={() => up(item.id)}>
      {item.count}
    </button>
  );
});
function App() {
  const [docList, setDocList] = React.useState([]);
  React.useEffect(
    () =>
      firebase.listen(({ type, doc }) => {
        if (type === 'add') {
          setDocList((current) => [...current, doc]);
        }
        if (type === 'edit') {
          setDocList((current) =>
            current.map((item) =>
              item.id === doc.id ? doc : item
            )
          );
        }
      }),
    []
  );
  const up = React.useCallback(
    (id) => firebase.edit(id),
    []
  );
  return (
    <div>
      <button onClick={() => firebase.add({ count: 0 })}>
        add
      </button>
      <div>
        {docList.map((doc) => (
          <Counter key={doc.id} up={up} item={doc} />
        ))}
      </div>
    </div>
  );
}
ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>


<div id="root"></div>

You can do setDocList(docList.map... but that makes docList a dependency of the effect: useEffect(function,[docList]) and the effect will run every time docList changes so you need to remove the listener and idd it every time.

In your code you did not add the dependency so docList was a stale closure. But the easiest way would be to do what I suggested and use callback for setDocList: setDocList(current=>current.map... so docList is not a dependency of the effect.

The comment:

I don't think setDocList, even with the the prevState function, is guaranteed to be up to date by the time you get into that if statement

Is simply not true, when you pass a callback to state setter the current state is passed to that callback.

HMR
  • 37,593
  • 24
  • 91
  • 160
  • you are very right i have checked and it worked. i am adding the working code to my answer below. thanks alot. – Capt Weiss Jun 19 '20 at 16:17