I'm currently building an app which allows a user to collect stamps by scanning a QR code. I have a Firebase Firestore snapshotlistener attached which listens for new stamps being added to that user. And rather than to fetch the new total amount of stamps for that user, I wanted to use the payload of the snapshot and add it to the list of stamps which is saved in a Context.
Unfortunately, this only works the first time when the app has been started. The procedure looks like this:
QR code gets scanned
New snapshot is received with newly added stamps (1 or more)
I'm logging the current state of total stamps to console, which looks OK.
The new stamps get added to the state context.
Logging the context state changes show the stamps were added correctly.
But the second time or any after that it looks like this:
QR code gets scanned
New snapshot is received with newly added stamps (1 or more)
I'm logging the current state of total stamps to console, but this still looks the same as before the first attempt.
The new stamps get added to the old state context.
Logging the context state changes show the stamps were added but the first was removed, so basically the first stamp has been overwritten.
You'd think this is simple, "just check you're dependency arrays". But this looks all okay to me.
Here's how my setup looks:
Context:
const AuthContextProvider = ({ children }: Props) => {
const [user, setUser] = useState<FirebaseAuthTypes.User>();
const [userData, setUserData] = useState<FirestoreUser>();
const [stamps, setStamps] = useState<GetMyStampsResponse>();
const [isLoading, setLoading] = useState(true);
// Unimportant for issue
const onAuthStateChanged = useCallback(
async (response: FirebaseAuthTypes.User | null) => {
...
},
[...]
);
useEffect(() => {
const unsubscribe = auth().onUserChanged(onAuthStateChanged);
return unsubscribe;
}, [onAuthStateChanged]);
return (
<AuthContext.Provider
value={{
isLoading,
stamps,
setStamps,
user,
userData,
reloadUser,
}}>
{children}
</AuthContext.Provider>
);
};
useAuth hook to be used in my components
import storage from '#react-native-firebase/storage';
import cloneDeep from 'lodash/cloneDeep';
import { useCallback, useContext } from 'react';
const useAuth = () => {
const { stamps, setStamps, reloadUser, userData, user, isLoading } =
useContext(AuthContext);
/**
* Add stamps that were received through a listener. This way we don't have to rely on reloadUser, which is more expensive.
*/
const addStampsFromListener = useCallback(
async (newStamps: FirestoreStamp[]) => {
let newStampsState = cloneDeep(stamps);
const newStampHasKnownStampcard = newStampsState?.cards.find(
c => c.ref.path === newStamps[0].stampcardRef.path,
);
console.log('current stamps state', newStampsState?.stamps.length);
if (newStampHasKnownStampcard) {
newStampsState = {
...newStampsState!,
stamps: newStampsState!.stamps.concat(newStamps),
};
} else {
const [stampcard, business] = await Promise.all([
stampcardApi.getStampcardByRef(newStamps[0].stampcardRef),
businessApi.getBusiness(newStamps[0].businessId),
]);
const imageUrl = await storage()
.ref(stampcard!.stampImage)
.getDownloadURL();
const stampcardWithRef: StampCardDataWithRef = {
...stampcard!,
imageUrl,
businessId: newStamps[0].businessId,
ref: newStamps[0].stampcardRef,
};
newStampsState = {
...newStampsState,
businesses: (newStampsState?.businesses ?? []).concat(business!),
cards: (newStampsState?.cards ?? []).concat(stampcardWithRef),
stamps: (newStampsState?.stamps ?? []).concat(newStamps),
};
}
setStamps(newStampsState);
return newStampsState;
},
[setStamps, stamps],
);
...
return {
addStampsFromListener,
updateStampsFromListener,
isLoading,
data: userData,
logout,
reloadUser,
stamps,
user,
};
};
And finally, the use of this context hook in my screen:
const HomeConsumerScreen = () => {
const {
stamps: stampcards,
reloadUser,
addStampsFromListener,
updateStampsFromListener,
} = useAuth();
const subscribeToStamps = useCallback(() => {
if (refIsSubscribedToStamps.current) return; // Prevent initialization when already running
const unsubscribe = stampApi.subscribeToStamps(
/**
* Callback for receiving new stamps
*/
querySnapshot => {
error && setError(undefined);
if (!refIsSubscribedToStamps.current) {
// First snapshot contains current state of all stamps belonging to a user
return;
}
const data = querySnapshot.map(doc =>
doc.doc.data(),
) as FirestoreStamp[];
addStampsFromListener(data).then(nextState =>
showDone('newStamp', data, nextState),
);
},
/**
* Initializer
*/
() => {
refIsSubscribedToStamps.current = true;
},
/**
* Error callback
*/
e => {
setError(e.message);
console.error('onSnapShot', e);
},
);
return unsubscribe;
}, [addStampsFromListener, error, showDone, updateStampsFromListener]);
useEffect(() => {
// Listen for changes to single stampcard
const unsubscribe = subscribeToStamps();
return () => {
unsubscribe && unsubscribe();
refIsSubscribedToStamps.current = false;
};
// Adding 'subscribeToStamps' to dependency hook will cause
// the listener to unmount&remount on every callback.
// It will fix the issue that I'm trying to solve here.... :(
}, []);
};
So it seems to revolve around adding subscribeToStamps to the useEffect dependency array, but that causes the listener to unmount&mount again which is just more costly.
Any advice?
Related
Problem
I am new to React and am trying to build an application whereby logged in users can view posts they have created. I am having issues with asynchronous functions causing variables to be accessed before they are loaded in. I am using a Firestore database.
Code
I followed this tutorial to set up authentication. I have created an AuthContext.js file, which contains this code (reduced):
const AuthContext = createContext();
export const AuthContextProvider = ({children}) => {
const [user, setUser] = useState({});
// const googleSignIn = () => {...}
// const logOut = () => {...}
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
setUser(currentUser);
});
return () => {
unsubscribe();
}
}, []);
return (
<AuthContext.Provider value={{ googleSignIn, logOut, user }}>
{children}
</AuthContext.Provider>
)
};
export const UserAuth = () => {
return useContext(AuthContext);
}
I then wrap my application with a AuthContextProvider component and import UserAuth into any component that I want to be able to access the user object from. I have a PostPage component, and in it I want to ONLY render posts created by the logged in user. Each post has a user property containing the uid of the author. Here is my code:
import { UserAuth } from './context/AuthContext'
const PostsPage = () => {
const { user } = UserAuth();
const [posts, setPosts] = useState([]);
const postsRef = collection(db, 'posts');
useEffect(() => {
const getData = async () => {
if (user) {
const q = query(postsRef, where('user', '==', user.uid));
const data = await getDocs(q);
const filtered = data.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
setPosts(filtered);
}
}
return () => {
getData();
}
}, [user]);
return (
// Display posts
)
}
export default PostsPage;
Upon immediately refreshing the page, getData is executed. However, the code wrapped in the if statement does not run because the user has not yet been loaded in. Yet despite the dependancy array, getData is not executed again once the user data loads in, and I can't figure out why. If I render the user's uid, e.g. <p>{ user.uid }</p>, it will soon appear on the screen after the data has been loaded. But, I cannot figure out how to trigger getData after the user has been loaded. Any help with this would be much appreciated, thanks.
You have an issue just because you put getData() call to the cleanup function of a hook. Cleanup function will execute on depsArray change but it will be executed with old data, closure captured. So when user changes from undefined => any - getUser will be called and will still have a closure-captured user set to undefined. You can clear the array instead in it, so if user logs out - dont show any messages
useEffect(() => {
const getData = async () => {
if (!user) return;
const q = query(postsRef, where("user", "==", user.uid));
const data = await getDocs(q);
const filtered = data.docs.map((doc) => ({
...doc.data(),
id: doc.id
}));
setPosts(filtered);
};
getData().catch(console.error);
return () => {
setPosts([]);
};
}, [user]);
Let's say I have a reusable component that, inside a useEffect, loads some data from an API, stores it inside a state which is then used by it in some way.
However, in some pages, this same component is reused in multiple places, making multiple calls for the same data.
So, I would like to have a cache of sorts, where I store the retrieved values and reuse it in other instances if it wasn't fetched too long ago.
I don't really want to store it in a global state like Redux or (partially) Context because this is just a small detail of a reusable component.
The only solution I could come up with was using an external variable as the cache:
import { useEffect, useState } from "react";
let cache = null;
let cacheFetchedTime = null;
export const MyDropdown = () => {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
if (cacheFetchedTime && Date.now() - cacheFetchedTime.getTime() < 5000) {
setData(cache);
return;
}
const fetchedData = await fetchData();
setData(fetchedData);
cache = fetchedData;
cacheFetchedTime = new Date();
};
fetchData();
}, []);
return (
<div>
{/* use `data` somehow */}
</div>
);
};
Is this the best (or only) way of doing this or is there a better alternative?
I decided to keep my cache at the top-level of the file where the component is at.
More specifically, I created an HOC that receives the getter function and returns a function that caches its answer for x period of time:
/* createCachedCall.js */
const createCachedCall = ({ callback, ms = 5000 }) => {
let ongoingAsyncCall = null
let result = null
let lastFetchDate = null
return async () => {
if (ongoingAsyncCall) return ongoingAsyncCall
if (lastFetchDate && Date.now() - lastFetchDate.getTime() <= ms)
return result
ongoingAsyncCall = Promise.resolve(callback())
result = await ongoingAsyncCall
lastFetchDate = new Date()
ongoingAsyncCall = null
return result
}
}
/* MyDropdown.jsx */
const getDataCached = createCachedCall({
callback: getData,
ms: 300000, // 5 minutes
})
export const MyDropdown = () => {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const data = await getDataCached();
};
fetchData();
}, []);
return (
<div>
{/* use `data` somehow */}
</div>
);
};
What I'm trying to do is fetch a single random quote from a random quote API every 5 seconds, and set it's contents to a React component.
I was able to fetch the request successfully and display it's contents, however after running setInterval method with the fetching method fetchQuote, and a 5 seconds interval, the contents are updated multiple times in that interval.
import { Badge, Box, Text, VStack, Container} from '#chakra-ui/react';
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const RandomQuotes = () => {
const [quote, setQuote] = useState<Quote>(quoteObject);
const [error, setError]: [string, (error: string) => void] = React.useState("");
const [loading, setLoading] = useState(true);
const fetchQuote = () => {
axios.get<Quote>(randomQuoteURL)
.then(response => {
setLoading(false);
setQuote(response.data);
})
.catch(ex => {
setError(ex);
console.log(ex)
});
}
setInterval(() => setLoading(true), 5000);
useEffect(fetchQuote, [loading, error]);
const { id, content, author } = quote;
return (
<>
<RandomQuote
quoteID={id}
quoteContent={content}
quoteAuthor={author}
/>
</>
);
}
When any state or prop value gets updated, your function body will re-run, which is called a re-render.
And you've put setInterval call in the main function(!!!), so each time the component re-renders, it will create another interval again and again. Your browser will get stuck after a few minutes.
You need this interval definition once, which is what useEffect with an empty second parameter is for.
Also, using loading flag as a trigger for an API call works, but semantically makes no sense, plus the watcher is expensive and not needed.
Here's a rough correct example:
useEffect(() => {
const myInterval = setInterval(fetchQuote, 5000);
return () => {
// should clear the interval when the component unmounts
clearInterval(myInterval);
};
}, []);
const fetchQuote = () => {
setLoading(true);
// your current code
};
I'm new to React Native code building.
Below is my React Native code to get data from Firebase.
const page_one = () => {
const [isLoading, setIsLoading] = useState(true)
const [placeList, setPlaceList] = useState([])
const [message, setMessage] = useState(false)
const db = firebase.firestore().collection('places')
const onLoad = async () => {
const place_ref = await firebase.firestore().collection('places').get()
if (place_ref.empty) {
setMessage(true)
return
}
const places = []
try {
place_ref.forEach(doc => {
const entity = doc.data()
entity.id = doc.id
places.push(entity)
})
setPlaceList(places)
setMessage(false)
setIsLoading(false)
return
} catch (error) {
console.log("Error:\n", error.message)
return
}
}
}
useEffect(() => {
onLoad()
console.log('place List')
}, [isLoading])
return (<View></View>)
}
I need to refresh the current component every time I render, to get newly added data from firebase. How to make possible this.
As of now component is not loading when I rendering the component 2nd time. it fetches the old data only, not loading the latest data still I refreshing the whole application.
I need to fetch the latest data whenever I render the component.
I tried with below useEffect hook:
useEffect(() => {
onLoad()
console.log('place List')
}, [isLoading, placeList])
But it calls the firebase request n number of times till I existing the current component.
I want to call the firebase request only once when ever I visiting the current component.
please help me..
As far as I understand you need to refresh whenever this component gets focused
So for that, write like this
useEffect(() => {
const unsubscribe = navigation.addListener("focus", () => {
onLoad() // Gets fired whenever this screen is in focus
});
return unsubscribe;
}, [navigation]);
Also don't forget to destructure the props to get the navigation prop
Like this
const page_one = ({ navigation }) => {
...Code Inside
}
I am working on a small CRUD fullstack app with react and mongodb and I have this problem where I use useEffect to make an axios get request to the server to get all of my todos. The problem is that useEffect does it's job but it also rerenders to infinity. This is my component:
export default function () {
...
const [todos, setTodos] = useState([]);
const currentUser = JSON.parse(localStorage.getItem('user'))._id;
useEffect(() => {
async function populateTodos () {
try {
const res = await axios.get(`http://localhost:8000/api/all-todos/${currentUser}`);
setTodos(res.data);
} catch (err) {
if (err.response) {
console.log(err.response.data);
console.log(err.response.status);
console.log(err.response.headers);
} else if (err.request) {
console.log(err.request);
} else {
console.log('Error: ', err.message);
}
}
}
populateTodos();
}, [todos]);
console.log(todos);
return (
...
);
}
So what I was expecting to happen is that that console.log to get printed only when the todos changes, like when I add a new todo and so on, but instead it gets printed forever.
You said that you need to fetch todos at first, and whenever todos change. I can suggest you a different approach, using one more variable, something like this:
const TodosComponent = (props) => {
const [todos, setTodos] = useState([]);
const [updatedTodos, setUpdatesTodos] = useState(true);
const fetchFunction = () => {
// In here you implement your fetch, in which you call setTodos().
}
// Called on mount to fetch your todos.
useEffect(() => {
fetchFunction();
}, []);
// Used to updated todos when they have been updated.
useEffect(() => {
if (updatedTodos) {
fetchFunction();
setUpdatesTodos(false);
}
}, [updatedTodos]);
// Finally, wherever you update your todos, you also write `updateTodos(true)`.
}