React Native: Duplicate items in component state - reactjs

I have a two screens in a StackNavigator, one with a FlatList that displays data retrieved from Firestore, and another to add a new data to the database. After returning from the second screen in the stack via navigation.goBack(), the new item should be appended to the list. Instead, the entire state with the new item is being appended to the old state. The database data contains no duplicates and upon refresh, the list contains the correct elements.
I can't tell if I'm misunderstanding the component lifecycle or the query itself so I would appreciate any help.
export default class Main extends React.Component {
state = { chatData:[] }
componentDidMount = () => {
// Make call to Cloud Firestore
// for the current user, retrieve the chat document associated with each element in the chats id array
let user = firebase.auth().currentUser;
firestore().collection("users").doc(user.uid).onSnapshot((doc) => {
doc.data().chats.map((element) => {
firestore().collection("chats").doc(element).onSnapshot((doc) => {
this.setState({chatData: [...this.state.chatData,
{id: element, subject: doc.data().subject, course: doc.data().course}]})
})
});
})
}
state after adding a course and returning to the list screen (duplicate element)

When setting state try to use the prevState callback function. Like so:
export default class Main extends React.Component {
state = { chatData:[] }
componentDidMount = () => {
// Make call to Cloud Firestore
// for the current user, retrieve the chat document associated with each element in the chats id array
let user = firebase.auth().currentUser;
firestore().collection("users").doc(user.uid).onSnapshot((doc) => {
doc.data().chats.map((element) => {
firestore().collection("chats").doc(element).onSnapshot((doc) => {
// We use the parameter of the first argument of setState - prevState
this.setState(prevState => ({chatData: [...prevState.chatData,
{id: element, subject: doc.data().subject, course: doc.data().course}]}))
})
});
})
}
Because you want to spread the state that was there previously like an accumulator with the new data you're getting from firestore. If you do it with this.state then you'll be adding it again since it concerns the data that is already in the state and therefore repeated/duplicated. Let me know if it helps.

Try to create a new array with unique values and assign that to chatData
componentDidMount = () => {
let user = firebase.auth().currentUser;
firestore().collection("users").doc(user.uid).onSnapshot((doc) => {
doc.data().chats.map((element) => {
firestore().collection("chats").doc(element).onSnapshot((doc) => {
/**
* create a new array with unique values
*/
let newArray = [...this.state.chatData, { id: element, subject: doc.data().subject, course: doc.data().course }]
let uniqueArray = [...new Set(newArray)]
this.setState({
chatData: uniqueArray
})
})
});
})
}
Hope this helps you. Feel free for doubts.

Here is my eventual solution. I'm using react-navigation addListener to call the firestore API whenever the first screen is switched to and clearing the state when the second screen is navigated to. I also switched from onSnapshot() to get() for my firestore calls.
class Main extends React.Component {
state = { currentUser: null, chatData:[]}
componentDidMount = () => {
console.log('A - component did mount')
// definitely works
this.willFocusSubscription = this.props.navigation.addListener(
'willFocus',
payload => {
console.log('A- focus')
this.readCourseData()
})
this.willBlurSubscription = this.props.navigation.addListener(
'willBlur',
payload => {
console.log('A- blur')
this.setState({chatData: []})
})
}
componentWillUnmount() {
console.log('A - component will unmount')
this.willFocusSubscription.remove();
this.willBlurSubscription.remove();
// Remove the event listener
}
readCourseData = () => {
// Make call to Cloud Firestore
// for the current user, retrieve the chat document associated with each element in the chats array
let user = firebase.auth().currentUser;
firestore().collection("users").doc(user.uid).get().then((doc) => {
doc.data().chats.map((element) => {
firestore().collection("chats").doc(element).get().then((doc) => {
let newArray = [...this.state.chatData, { id: element, subject: doc.data().subject, course: doc.data().course }]
let uniqueArray = [...new Set(newArray)]
this.setState({
chatData: uniqueArray
})
})
});
})
}

Related

How to avoid firing multiple redux actions with real time firestore listeners

Introduction
A Little Warning: I do use Redux Toolkit
I have bunch of lists, one of which should be active. And depending on some context, active list should be different. For example I have 3 lists (A, B, C) and let's look at following patterns:
List B is active and I decided to create a new list. After creating list D, list D should be active:
List D - active
List C
List B
List A
List B is active and I decided to change the page. When I come back, List B should be active as it was before changing the page.
The problem
As I initiate the setListsAction from the beginning, it always listens to the firestore and gets invoked every time I manipulate with the store (add, remove, update) and then pass all the data to the reducer. For this reason, I can't control which action was actually performed. For this case in my setListsReducer I check if there's already an active list, if so, I don't change it (covering my second pattern in the examples section). However, with such logic I can't set newly created list as active, because there'll be always an active list that's why in my createListAction I pas a newly created list to the payload and in createListReducer I set payload as the active list. However, the caveat of this approach is that both setListsAction and createListAction gets triggered, so redux state gets updated two times in a row, making my components rerender unnecessary. The cycle looks like that:
in createListAction I add list to the firestore
firestore was updated, so setListsAction gets triggered
createListAction dispatches fulfilled action.
My Code
Actions
setListsAction
export const subscribeListsAction = () => {
return async (dispatch) => {
dispatch(fetchLoadingActions.pending());
const collection = await db.collection('lists');
const unsubscribe = collection
.onSnapshot((querySnapshot) => {
const lists = querySnapshot.docs.map((doc) => {
const list = { ...doc.data(), id: doc.id };
return list;
});
dispatch(
fetchLoadingActions.fulfilled({
lists,
})
);
});
};
};
createListAction
export const createListActionAsync = (list) => {
return async (dispatch: Dispatch<PayloadAction<any>>) => {
dispatch(listsLoadingActions.pending());
const docList = await db.collection('lists').add(list);
const fbList = { ...list, id: docList.id };
dispatch(listsLoadingActions.fulfilled(fbList));
};
};
Reducers
setListsReducer
builder.addCase(fetchLoadingActions.fulfilled, (state, { payload }) => {
state.lists = payload.lists;
const activeList = state.activeList
? payload.lists.find((l) => l.id === state.activeList.id)
: payload.lists[0];
state.activeList = activeList;
});
createListReducer
builder.addCase(listsLoadingActions.fulfilled, (state, { payload }) => {
state.activeList = payload;
});
Sum
So I would like you to propose a better way to handle my problem. I tried to solve it, using change type on docChanges but when I init setListsAction, all docs' changes are type of added and workarounds may damage further implementations of the app. Probably, I need to give up real time database and use get method instead.
If you eliminate the createListReducer and listLoadingActions, you should be able to do everything from inside the ListsAction hook. Using await db.collection('lists').add(list) should refire the listener on the lists collection once it's been added to the database successfully.
export const subscribeListsAction = () => {
return async (dispatch) => {
dispatch(fetchLoadingActions.pending());
const collection = db.collection('lists'); // no need to await?
let firstLoad = true; // used to determine whether to use docs or docsChanges
const unsubscribe = collection
.onSnapshot((querySnapshot) => {
if (firstLoad) {
const lists = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
firstLoad = false;
// Get and set initial active list?
dispatch(
fetchLoadingActions.fulfilled({
lists,
})
);
} else {
// optionally fire dispatch(fetchLoadingActions.pending()) again?
const listsCopy = [...state.lists]; // copy the existing list to mutate it
let activeList = state.activeList; // store the current activeList
querySnapshot.docChanges().map((change) => {
if (change.type === "added") {
const thisList = { ...change.doc.data(), id: change.doc.id };
listsCopy.splice(change.newIndex, 0, thisList);
activeList = thisList;
} else if (change.type === "modified") {
listsCopy.splice(change.oldIndex, 1);
listsCopy.splice(change.newIndex, 0, { ...change.doc.data(), id: change.doc.id });
} else if (change.type === "removed") {
listsCopy.splice(change.oldIndex, 1);
if (activeList.id === change.doc.id) {
// the current active list was removed!
activeList = undefined;
}
}
});
dispatch(
fetchLoadingActions.fulfilled({
lists: listsCopy,
activeList: activeList || listsCopy[0] // use activeList or fallback to first list in listsCopy, could still be undefined if listsCopy is empty!
})
);
}
});
return unsubscribe;
};
};
Regarding the active list history, you could either use the URL ?list=some-id to store the selected list with the History API or you could store an array called activeListHistory in your state variable where you push() and pop() to it as necessary (make sure to handle cases where the old list no longer exists and where there are no entries in the array).

Firestore: calling collections.get() inside promise()

useEffect(() => {
if (!stop) {
// get current user profile
db.collection('events').get(eventId).then((doc) => {
doc.forEach((doc) => {
if (doc.exists) {
let temp = doc.data()
let tempDivisions = []
temp["id"] = doc.ref.id
doc.ref.collection('divisions').get().then((docs) => {
docs.forEach(doc => {
let temp = doc.data()
temp["ref"] = doc.ref.path
tempDivisions.push(temp)
});
})
temp['divisions'] = tempDivisions
setEvent(temp)
setStop(true)
// setLoading(false);
} else {
// doc.data() will be undefined in this case
console.log("No such document!");
<Redirect to="/page-not-found" />
}
})
})
}
}, [stop, eventId]);
I am curious if this is the properly way to extract nested data from Cloud Firestore.
Data model:
Collection(Events) -> Doc(A) -> Collection(Divisions) -> Docs(B, C, D, ...)
Pretty much I'm looking to get metadata from Doc(A), then get all the sub-collections which contain Docs(B, C, D, ...)
Current Problem: I am able to get meta data for Doc(A) and its subcollections(Divisions), but the front-end on renders metadata of Doc(A). Front-End doesn't RE-RENDER the sub-collections even though. However, react devtools show that subcollections(Divisions) are available in the state.
EDIT 2:
const [entries, setEntries] = useState([])
useEffect(() => {
let active = true
let temp = []
if (active) {
divisions.forEach((division) => {
let teams = []
let tempDivision = division
db.collection(`${division.ref}/teams`).get().then((docs) => {
docs.forEach((doc, index) => {
teams.push(doc.data())
})
tempDivision['teams'] = teams
})
setEntries(oldArray => [...oldArray, temp])
})
}
return () => {
active = false;
};
}, [divisions]);
is there any reason why this is not detecting new array and trigger a new state and render? From what I can see here, it should be updating and re-render.
Your inner query doc.ref.collection('divisions').get() doesn't do anything to force the current component to re-render. Simply pushing elements into an array isn't going to tell the component that it needs to render what's in that array.
You're going to have to use a state hook to tell the component to render again with new data, similar to what you're already doing with setEvent() and setStop().

Duplicate lists printing after adding new document to firestore collection

When the component loads, it pulls all the data from a specific collection in firestore and renders it just fine. then when i add a new document, it adds that document but then prints them all out (including the new one) under the previous list.
This is my first real react project and I am kinda clueless. I have tried resetting the state when the component loads and calling the method at different times.
import React, { Component } from 'react';
// Database Ref
import Firebase from '../../Config/Firebase';
// Stylesheet
import '../View-Styles/views.scss';
// Componenents
import Post from '../../Components/Post/Post';
import EntryForm from '../../Components/EntryForm/EntryForm';
export class Gym extends Component {
constructor() {
super();
this.collection = 'Gym';
this.app = Firebase;
this.db = this.app.firestore().collection('Gym');
this.state = {
posts: []
};
this.addNote = this.addNote.bind(this);
};
componentDidMount() {
this.currentPosts = this.state.posts;
this.db.onSnapshot((snapshot) => {
snapshot.docs.forEach((doc) => {
this.currentPosts.push({
id: doc.id,
// title: doc.data().title,
body: doc.data().body
});
});
this.setState({
posts: this.currentPosts
});
});
};
addNote(post) {
// console.log('post content:', post );
this.db.add({
body: post
})
}
render() {
return (
<div className="view-body">
<div>
{
this.state.posts.map((post) => {
return(
<div className="post">
<Post key={post.id} postId={post.id} postTitle={post.title} postBody={post.body} />
</div>
)
})
}
</div>
<div className="entry-form">
<EntryForm addNote={this.addNote} collection={this.collection} />
</div>
</div>
)
};
};
export default Gym;
I am trying to get it to only add the new document to the list, rather than rendering another complete list with the new document. no error messages.
Your problem lies with your componentDidMount() function and the use of onSnapshot(). Each time an update to your collection occurs, any listeners attached with onSnapshot() will be triggered. In your listener, you add each document in the snapshot to the existing list. While this list starts off empty, on every subsequent change, the list is appended to with all of the documents in the collection (including the old ones, not just the changes).
There are two ways to handle the listener's snapshot when it comes in - either empty the existing list and recreate it on each change, or only handle the changes (new entries, deleted entries, etc).
As a side note: When using onSnapshot(), it is recommended to store the "unsubscribe" function that it returns (e.g. this.stopChangeListener = this.db.onSnapshot(...)). This allows you later to freeze the state of your list without receiving further updates from the server by calling someGym.stopChangeListener().
Recreate method
For simplicity, I'd recommend using this method unless you are dealing with a large number of items.
componentDidMount() {
this.stopChangeListener = this.db.onSnapshot((snapshot) => {
var postsArray = snapshot.docs.map((doc) => {
return {
id: doc.id,
// title: doc.data().title,
body: doc.data().body
});
});
this.currentPosts = postsArray;
this.setState({
posts: postsArray
});
});
};
Replicate changes method
This method is subject to race-conditions and opens up the possibility of desyncing with the database if handled incorrectly.
componentDidMount() {
this.stopChangeListener = this.db.onSnapshot((snapshot) => {
var postsArray = this.currentPosts.clone() // take a copy to work with.
snapshot.docChanges().forEach((change) => {
var doc = change.document;
var data = {
id: doc.id,
// title: doc.data().title,
body: doc.data().body
});
switch(change.type) {
case 'added':
// add new entry
postsArray.push(data)
break;
case 'removed':
// delete potential existing entry
var pos = postsArray.findIndex(entry => entry.id == data.id);
if (pos != -1) {
postsArray.splice(pos, 1)
}
break;
case 'modified':
// update potential existing entry
var pos = postsArray.findIndex(entry => entry.id == data.id);
if (pos != -1) {
postsArray.splice(pos, 1, data)
} else {
postsArray.push(data)
}
}
});
this.currentPosts = postsArray; // commit the changes to the copy
this.setState({
posts: postsArray
});
});
};
As a side note: I would also consider moving this.currentPosts = ... into the this.setState() function.
When you use onSnapshot() in Cloud Firestore,you can print only the added data. For your code, it should be something like:
snapshot.docChanges().forEach(function(change) {
if (change.type === "added") {
console.log("Newly added data: ", change.doc.data());
}
Also, Firestore does not load the entire collection everytime a new data is added, the documents are cached and will be reused when the collection changes again.
For more info, you can checkout this answer.

Using componentWillUpdate with switch statements

I am using React-Native and React-Native-Firebase and am trying to have my Events component make a different Firebase query (and then update redux store) depending what the value of the activityType prop is.
Here is the parent component which is working just fine. It updates state.eventType when I change the dropdown value and passes the value into <Events />.
let eventTypes = [{value: 'My Activity'}, {value: 'Friend Activity'}, {value: 'All Activity'}];
state = {
showEventFormModal: false,
eventType: 'Friend Activity'
}
<View style={styles.container}>
<Dropdown
data={eventTypes}
value={this.state.eventType}
containerStyle={{minWidth: 200, marginBottom: 20}}
onChangeText={val => this.setState({eventType: val})}
/>
<Events activityType={this.state.eventType}/>
</View>
And here is Events component. Using a Switch statement to determine which activityType was passed into props. The issue I am having is an infinite loop because within each case statement I am dispatching the action to update the store which causes a rerender and the componentWillUpdate() to retrigger. What I am trying to understand is what the optimal way to handle this problem is? Because clearly my method now does not function properly. Is there a common react pattern to achieve this?
// GOAL: when this components props are updated
// update redux store via this.props.dispatch(updateEvents(events))
// depending on which type of activity was selected
componentWillUpdate() {
let events = [];
switch(this.props.activityType) {
case 'Friend Activity': // get events collections where the participants contains a friend
// first get current users' friend list
firebase.firestore().doc(`users/${this.props.currentUser.uid}`)
.get()
.then(doc => {
return doc.data().friends
})
// then search the participants sub collection of the event
.then(friends => {
firebase.firestore().collection('events')
.get()
.then(eventsSnapshot => {
eventsSnapshot.forEach(doc => {
const { type, date, event_author, comment } = doc.data();
let event = {
doc,
id: doc.id,
type,
event_author,
participants: [],
date,
comment,
}
firebase.firestore().collection('events').doc(doc.id).collection('participants')
.get()
.then(participantsSnapshot => {
for(let i=0; i<participantsSnapshot.size;i++) {
if(participantsSnapshot.docs[i].exists) {
// if participant uid is in friends array, add event to events array
if(friends.includes(participantsSnapshot.docs[i].data().uid)) {
// add participant to event
let { displayName, uid } = participantsSnapshot.docs[i].data();
let participant = { displayName, uid }
event['participants'].push(participant)
events.push(event)
break;
}
}
}
})
.then(() => {
console.log(events)
this.props.dispatch(updateEvents(events))
})
.catch(e => {console.error(e)})
})
})
.catch(e => {console.error(e)})
})
case 'My Activity': // get events collections where event_author is the user
let counter = 0;
firebase.firestore().collection('events').where("event_author", "==", this.props.currentUser.displayName)
.get()
.then(eventsSnapshot => {
eventsSnapshot.forEach(doc => {
const { type, date, event_author, comment } = doc.data();
let event = {
doc,
id: doc.id,
type,
event_author,
participants: [],
date,
comment,
}
// add participants sub collection to event object
firebase.firestore().collection('events').doc(event.id).collection('participants')
.get()
.then(participantsSnapshot => {
participantsSnapshot.forEach(doc => {
if(doc.exists) {
// add participant to event
let { displayName, uid } = doc.data();
let participant = { displayName, uid }
event['participants'].push(participant)
}
})
events.push(event);
counter++;
return counter;
})
.then((counter) => {
// if all events have been retrieved, call updateEvents(events)
if(counter === eventsSnapshot.size) {
this.props.dispatch(updateEvents(events))
}
})
})
})
case 'All Activity':
// TODO
// get events collections where event_author is the user
// OR a friend is a participant
}
}
Updating the store is best to do on the user action. So, I'd update the store in the Dropdown onChange event vs. in the componentWillUpdate function.
I've figured out what feels like a clean way to handle this after finding out I can access prevProps via componentDidUpdate. This way I can compare the previous activityType to the current and if they have changed, then on componentDidUpdate it should call fetchData(activityType).
class Events extends React.Component {
componentDidMount() {
// for initial load
this.fetchData('My Activity')
}
componentDidUpdate(prevProps) {
if(this.props.activityType !== prevProps.activityType) {
console.log(`prev and current activityType are NOT equal. Fetching data for ${this.props.activityType}`)
this.fetchData(this.props.activityType)
}
}
fetchData = (activityType) => {
//switch statements deciding which query to perform
...
//this.props.dispatch(updateEvents(events))
}
}
https://reactjs.org/docs/react-component.html#componentdidupdate

ReactJS+FireStore Data mapping issue

Im writing a small program to fetch the categories from the Firestore DB and show in webpage as a list.
My code look like this:
class Category extends Component {
constructor() {
super();
this.state = {'Categories': []}
}
render() {
let categoryList = null;
if (Array.isArray(this.state.Categories)) {
console.log(this.state.Categories);
categoryList = this.state.Categories.map((category) => {
return <li>{category.name}</li>
});
}
return(
<ul>{categoryList}</ul>
);
}
componentWillMount() {
// fetch the data from the Google FireStore for the Category Collection
var CategoryCollection = fire.collection('Category');
let categories = [];
CategoryCollection.get().then((snapshot)=> {
snapshot.forEach ((doc) => {
categories.push(doc.data());
});
}).catch((error) => {
console.log("Error in getting the data")
});
this.setState({'Categories': categories});
}
}
Im able to fetch the data and even populate this.state.Categories, however the map function is not getting executed.
The console.log statement produce an array of values butthe map function in render is not getting executed. Any thoughts?
Console.log output:
You have an error in handling data retrieval. In the last line categories is still empty, so it triggers setState with an empty data set. Should be something lie that
componentWillMount() {
fire.collection('Category').get()
.then(snapshot => {
const categories = snapshot.map(doc => doc.data());
// sorry, but js object should be pascal cased almost always
this.setState({ categories });
})
.catch(error => {
console.log("Error in getting the data")
});
}
Only return the data if the data exists. The simplest way to do this is to replace <ul>{categoryList}</ul> with <ul>{this.state.categories && categoryList}</ul>
I could make it work with a small change (moved this.setState to be inside the callback). Honestly, I still don't understand the difference.
P.S: I come from PHP development and this is my first step into ReactJS.
componentWillMount() {
// fetch the data from the Google FireStore for the Category Collection
var categoryCollection = fire.collection('Category');
let categories = [];
categoryCollection.get().then((snapshot)=> {
snapshot.forEach ((doc) => {
categories.push(doc.data());
});
if (categories.length > 0) {
this.setState({'Categories': categories});
}
}).catch((error) => {
console.log("Error in getting the data");
});
// this.setState({'Categories': categories});
}

Resources