React: SetState of Functional Component from Within a Callback - reactjs

My top level functional component App has a Promise-returning function req() that will be called from many child components. Internally, req() updates App's state to display that it was called (and why), then calls a different promise-returning function. Here is req():
//wrap all requests to track and display their progress
function req(func, args, waitCap, yayCap) {
//perform a callback on a given req, then update state
const withReq = (argId, callback) => {
let newReqs = state.reqList.map ( r => r); //copy the reqList
for (let reqIndex = 0; reqIndex < newReqs.length; reqIndex++) { //iterate through the list
if ((newReqs[reqIndex] && (newReqs[reqIndex].id === argId))) { //find a match
callback(newReqs[reqIndex]); //pass it to the callback
break;
}
}
setState( prevState => ({
...prevState,
reqList:newReqs,
}));
}
//kill a req and update state
const deleteReq = argId => {
let newReqs = state.reqList.filter( r => { //new reqList is the same list with no objects containing the argID
return r.id !== argId;
});
setState( prevState => ({
...prevState,
reqList:newReqs,
}));
}
//duplicate the req list
let newReqs = state.reqList.map( r => r );
const now = new Date(); //create a unique ID for this req for tracking
const reqId = [
now.getFullYear(),
String(now.getMonth()+1).padStart(2,"0"),
String(now.getDate()).padStart(2,"0"),
String(now.getHours()).padStart(2,"0"),
String(now.getMinutes()).padStart(2,"0"),
String(now.getSeconds()).padStart(2,"0"),
String(Math.floor(Math.random()*10000)).padStart(4,"0"),
].join("");
newReqs.push({ //add the new req to the new reqList
status:"waiting",
caption:waitCap,
id:reqId,
});
setState( prevState => ({ //render the changed list of Reqs
...prevState,
reqList:newReqs,
}));
return ServerCalls[func](args)
.then((res)=>{
withReq(reqId, foundReq =>{ //update the req to show success
foundReq.status="success";
foundReq.caption=yayCap;
});
setTimeout(() => {
deleteReq(reqId); //remove it from display after 3 seconds
}, 3000);
return res;
})
.catch((err)=>{
withReq(reqId, foundReq =>{ //update the req to show failure
foundReq.status="failure";
foundReq.caption=foundReq.caption+" Failed!";
});
setTimeout(() => {
deleteReq(reqId); //remove it from display after 3 seconds
}, 3000);
throw err;
})
}
The problem here is that the callback functions in Promise.then() and Promise.catch() operate on state's initial value and not the value it has when the callback executes, due to scoping. This isn't a problem with class components, just functional ones.
Is there a way for a functional component to read its execution-time state from within a callback? Or is a workaround required?

There are 2 problems here:
You're mutating the existing state when you do
withReq(reqId, foundReq => { //update the req to show success
foundReq.status = "success";
foundReq.caption = yayCap;
});
Never mutate state in React - it can cause re-rendering problems.
The value in the .then callback is stale. Fix this by passing to callback the current (newly updated) state in a state setter function, instead of the old state:
const withReq = (argId, callback) => {
setState(prevState => ({
...prevState,
reqList: prevState.reqList.map(req => (
req.id === argId ? callback(req) : req
)),
}));
}
Then make sure callback doesn't mutate, but creates and returns a new object instead, eg:
withReq(reqId, foundReq => ({
...foundReq,
status: "success",
caption: yayCap,
}));

Related

useState not updating rendered values after getting document snapshots

I am having a problem assigning data to useState by fetching data using reference type value from firebase.
const [preOil, setPreOil] = useState([]);
const [oilChange, setOilChange] = useState([]);
useEffect(() => {
getDocs(query(collection(db, "oil"), orderBy("timestamp"))).then(
(snapshot) => {
setPreOil(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}))
);
}
);
}, []);
useEffect(() => {
let current = preOil.length > 0 ? [...preOil] : [];
current.map((_oil, i) => {
getDoc(_oil.oilReference).then((oilRef) => {
current[i].oilReference = oilRef.data();
});
});
setOilChange(current);
}, [preOil]);
In the first useEffect, the data is fetched successfully in this form,
preOil = {
id:"RxbAOAIs8d3kGOHNskJ4",
oilReference: Ta {converter: null, _key: ut, type: 'document', firestore: Na},
customerName: "USAMA",
}
In the second useEffect based on [preOil], I need to reassign the oilReference with the data fetched from firestorm through its reference(oilReference), The data is successfully fetched from the database and assigned to current but The main problem is when I set to state setOilChange(current) it updates my oilChange state when I inspect in inspect tools in chrome but in JSX the changes don't reflect
I am updating the state in useEffect
I am having desired data assigned in a local variable and assign that variable to the state
Then What am I missing?
In your second useEffect(), more specifically, in
current.map((_oil, i) => {
getDoc(_oil.oilReference).then((oilRef) => {
current[i].oilReference = oilRef.data();
});
});
setOilChange(current);
You are mutating the content of the current variable. This mutation, because it is async, will happen after the setOilChange call. Such mutation will thus not trigger a re-render.
What you need is to instead first wait for all the docs to be loaded and only after that set the state. Example:
const _docs = current.map((_oil, i) => {
return getDoc(_oil.oilReference).then((oilRef) => { // changed here
return { // changed here
...current[i], // changed here
oilReference: oilRef.data() // changed here
} // changed here
}); // changed here
});
Promise.all(_docs).then(() => {
setOilChange(_docs);
});
Also notice I didn't mutate current, rather I returned a new object. This is not mandatory but just a best practice overall.

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

How to handle array state filter clashes

I am currently having an issue where multiple setStates that use the filtering of an array are interfering with each other. Basically if a user uploads two files, and they complete around the same time, one of the incomplete files may fail to be filtered from the array.
My best guess is that this is happening because they are separately filtering out the one that needs to be filtered, when the second one finishes and goes to filter itself out of the array, it still has the copy of the old incomplete array where the first file has not been filtered out yet. What would be a better way to approach this? Am I missing something obvious? I am thinking of using an object to hold the files instead, but then I would need to create a custom mapping function for the rendering part so that it can still be rendered as if were an array.
fileHandler = (index, event) =>{
let incompleteFiles = this.state.incompleteFiles
incompleteFiles[index].loading = true
incompleteFiles[index].file = event.target.files[0]
this.setState({ incompleteFiles: incompleteFiles },()=>{
const fileData = new FormData()
fileData.append('file', event.targets[0].file)
let incompleteFiles = this.state.incompleteFiles
let completeFiles = this.state.completeFiles
api.uploadFile(fileData)
.then(res=>{
if(res.data.success){
this.setState(state=>{
let completeFile = {
name : res.data.file.name,
}
completeFiles.push(completeFile)
incompleteFiles = incompleteFiles.filter(inc=>inc.label !== res.data.file.name)
return{
completeFiles,
incompleteFiles
}
})
}
})
})
}
Updated with accepted answer with a minor tweak
fileHandler = (index, event) =>{
this.setState(({ incompleteFiles }) => ({
// Update the state in an immutable way.
incompleteFiles: [
...incompleteFiles.slice(0, index),
{
...incompleteFiles[index],
loading: true,
file: event.target.files[0],
},
...incompleteFiles.slice(index+1)
],
}), () => {
const fileData = new FormData()
fileData.append('file', event.targets[0].file)
api.uploadFile(fileData)
.then(res => {
if(res.data.success){
this.setState(({ incompleteFiles, completeFiles }) => ({
completeFiles: [
...completeFiles, // Again, avoiding the .push since it mutates the array.
{ // The new file.
name: res.data.file.name,
}
],
incompleteFiles: incompleteFiles.filter(inc=>inc.label !== res.data.file.name),
})))
}
})
});
}
In class components in React, when setting the state which is derived from the current state, you should always pass a "state updater" function instead of just giving it an object of state to update.
// Bad
this.setState({ counter: this.state.counter + 1 });
// Good
this.setState((currentState) => ({ counter: currentState.counter + 1 }));
This ensures that you are getting the most up-to-date version of the state. The fact that this is needed is a side-effect of how React pools state updates under the hood (which makes it more performant).
I think if you were to re-write your code to make use of this pattern, it would be something like this:
fileHandler = (index, event) =>{
this.setState(({ incompleteFiles }) => ({
// Update the state in an immutable way.
incompleteFiles: {
[index]: {
...incompleteFiles[index],
loading: true,
file: event.target.files[0],
},
},
}), () => {
const fileData = new FormData()
fileData.append('file', event.targets[0].file)
api.uploadFile(fileData)
.then(res => {
if(res.data.success){
this.setState(({ incompleteFiles, completeFiles }) => ({
completeFiles: [
...completeFiles, // Again, avoiding the .push since it mutates the array.
{ // The new file.
name: res.data.file.name,
}
],
incompleteFiles: incompleteFiles.filter(inc=>inc.label !== res.data.file.name),
})))
}
})
});
}
Another thing to keep in mind is to avoid mutating your state objects. Methods like Array.push will mutate the array in-place, which can cause issues and headaches.
I think change code to this can solve your problem and make code easy to read.
fileHandler = async (index, event) =>{
const incompleteFiles = [...this.state.incompleteFiles]
incompleteFiles[index].loading = true
incompleteFiles[index].file = event.target.files[0]
this.setState(
{
incompleteFiles
},
async (prev) => {
const fileData = new FormData()
fileData.append('file', event.targets[0].file)
const res = await api.uploadFile(fileData)
/// set loading state to false
incompleteFiles[index].loading = false
if (!res.data.success) {
return { ...prev, incompleteFiles }
}
// add new file name into completeFiles and remove uploaded file name from incompleteFiles
return {
...prev,
completeFiles: [...prev.completeFiles, { name : res.data.file.name }],
incompleteFiles: incompleteFiles.filter(inc=>inc.label !== res.data.file.name)
}
})
)
}

React State is not updated with socket.io

When page loaded first time, I need to get all information, that is why I am calling a fetch request and set results into State [singleCall function doing that work]
Along with that, I am connecting websocket using socket.io and listening to two events (odds_insert_one_two, odds_update_one_two), When I got notify event, I have to
check with previous state and modify some content and update the state without calling again fetch request. But that previous state is still [] (Initial).
How to get that updated state?
Snipptes
const [leagues, setLeagues] = useState([]);
const singleCall = (page = 1, params=null) => {
let path = `${apiPath.getLeaguesMatches}`;
Helper.getData({path, page, params, session}).then(response => {
if(response) {
setLeagues(response.results);
} else {
toast("Something went wrong, please try again");
}
}).catch(err => {
console.log(err);
})
};
const updateData = (record) => {
for(const data of record) {
var {matchId, pivotType, rateOver, rateUnder, rateEqual} = data;
const old_leagues = [...leagues]; // [] becuase of initial state value, that is not updated
const obj_index = old_leagues.findIndex(x => x.match_id == matchId);
if(obj_index > -1) {
old_leagues[obj_index] = { ...old_leagues[obj_index], pivotType, rateOver: rateOver, rateUnder:rateUnder, rateEqual:rateEqual};
setLeagues(old_leagues);
}
}
}
useEffect(() => {
singleCall();
var socket = io.connect('http://localhost:3001', {transports: ['websocket']});
socket.on('connect', () => {
console.log('socket connected:', socket.connected);
});
socket.on('odds_insert_one_two', function (record) {
updateData(record);
});
socket.on('odds_update_one_two', function (record) {
updateData(record);
});
socket.emit('get_odds_one_two');
socket.on('disconnect', function () {
console.log('socket disconnected, reconnecting...');
socket.emit('get_odds_one_two');
});
return () => {
console.log('websocket unmounting!!!!!');
socket.off();
socket.disconnect();
};
}, []);
The useEffect hook is created with an empty dependency array so that it only gets called once, at the initialization stage. Therefore, if league state is updated, its value will never be visible in the updateData() func.
What you can do is assign the league value to a ref, and create a new hook, which will be updated each time.
const leaguesRef = React.useRef(leagues);
React.useEffect(() => {
leaguesRef.current = leagues;
});
Update leagues to leaguesRef.current instead.
const updateData = (record) => {
for(const data of record) {
var {matchId, pivotType, rateOver, rateUnder, rateEqual} = data;
const old_leagues = [...leaguesRef.current]; // [] becuase of initial state value, that is not updated
const obj_index = old_leagues.findIndex(x => x.match_id == matchId);
if(obj_index > -1) {
old_leagues[obj_index] = { ...old_leagues[obj_index], pivotType, rateOver:
rateOver, rateUnder:rateUnder, rateEqual:rateEqual};
setLeagues(old_leagues);
}
}
}

React Concurrent Modify Same State

I get errors while modifying a state concurrently.
I have a function called getAvailableSpace(i, newValue) which fetch data from the back end and set to the state. This function can be called concurrently. The errors happen when it is called before the last call finished.
getAvailableSpace(i, newValue) {
this.setState(() => {
let schema_filters = _.cloneDeep(this.state.schema_filters);
schema_filters[i].loadingSpaces = true;
return {schema_filters: schema_filters}
}, () => {
this.invokeAPI("sitecomparsion", "getallspaces" + this.props.router.location.search + "&selectedTdid=" + newValue.value, requestHeaders.getHeader, (data) => {
let schema_filters = _.cloneDeep(this.state.schema_filters);
if(data) {
//schema_filters[i].availableSpaces = {...data.spaces, ...data.areas}
let spaces= (Object.keys(data.spaces).map((key) =>{
return {label: data.spaces[key], value: ('space_' + Number(key))}
}))
schema_filters[i].availableSpaces = [];
schema_filters[i].availableSpaces.push({label: 'Spaces:', type: 'optgroup'})
schema_filters[i].availableSpaces = schema_filters[i].availableSpaces.concat(areas)
}
this.setState((prevState) => {
prevState.schema_filters = schema_filters;
return prevState;
});
});
});
}
The problem I believe is caused by the race condition since two calls are trying to modify the same state.
Also, I think if the _.cloneDeep(this.state.schema_filters) happens before the last call is finished, the update from the last call is lost.
Can I get some help?
If you have a lot of status to update at once, group them all in the same set. State:
this.setState(() => {
this.setState(() => {
let schema_filters = _.cloneDeep(this.state.schema_filters);
schema_filters[i].loadingSpaces = true;
return {schema_filters: schema_filters}
this.setState({schema_filters: schema_filters }, () => {
// Do something here.
});
});

Resources