React SetState Overwriting Entire Object Instead of Merging - javascript

My state looks like this in the constructor:
this.state = {
selectedFile: null, //current file selected for upload.
appStatus: 'waiting for zip...', //status view
zipUploaded: false,
zipUnpacked: false,
capturingScreens: false,
finishedCapture: false,
htmlFiles: null,
generatedList: [],
optionValues: {
delayValue: 1
},
sessionId: null,
estimatedTime: null,
zippedBackupFile: null,
secondsElapsed:0,
timer: {
screenshotStart:0,
screenshotEnd:0,
timingArray:[],
averageTimePerUnit:25,
totalEstimate:0
}
};
I have the following functions in my app.js:
this.secondsCounter = setInterval(this.countSeconds, 1000); // set inside the constructor
getStateCopy = () => Object.assign({}, this.state);
countSeconds = () => {
let stateCopy = this.getStateCopy();
let currentSeconds = stateCopy.secondsElapsed + 1;
this.setState({secondsElapsed:currentSeconds});
}
captureTime = (startOrStop) => {
let stateCopy = this.getStateCopy();
let secondsCopy = stateCopy.secondsElapsed;
let startPoint;
if(startOrStop === true) {
this.setState({timer:{screenshotStart:secondsCopy}});
} else if(startOrStop === false){
this.setState({timer:{screenshotEnd:secondsCopy}});
startPoint = stateCopy.timer.screenshotStart;
stateCopy.timer.timingArray.push((secondsCopy-startPoint));
this.setState({secondsElapsed:secondsCopy})
stateCopy.timer.averageTimePerUnit = stateCopy.timer.timingArray.reduce((a,b) => a + b, 0) / stateCopy.timer.timingArray.length;
this.setState({secondsElapsed:secondsCopy})
this.setState({timer:{averageTimePerUnit:stateCopy.timer.averageTimePerUnit}})
}
I'm getting an error that "push" does not exist on stateCopy.timer.timingArray. I did some investigation and found that this.setState({timer:{screenshotStart:secondsCopy}}); is actually overwriting the entire "timer" object in state and removing all of the previous properties instead of merging them.
I don't understand what I'm doing wrong.. I'm using stateCopy to avoid mutating state, and to get proper values (avoiding asynchronous confusion). Every article I read online about react suggests that writing an object to state will merge with whatever is already there, so why does it keep overwriting "timer" instead of merging??

I did some investigation and found that this.setState({timer:{screenshotStart:secondsCopy}}); is actually overwriting the entire "timer" object in state and removing all of the previous properties instead of merging them.
Correct. setState only handles merging at the top level. Anything below that you have to handle yourself. For instance:
this.setState(({timer}) => {timer: {...timer, screenshotStart: secondsCopy}});
Note the use of the callback version of setState. It's important to do that any time you're providing state information that's dependent on existing state.
There are other places you have to do the same sort of thing, including when you push to the array. Here are some further notes:
There's no reason to copy state here:
countSeconds = () => {
let stateCopy = this.getStateCopy();
let currentSeconds = stateCopy.secondsElapsed + 1;
this.setState({secondsElapsed: currentSeconds});
}
...and (as I mentioned above) you must use the callback form to reliably modify state based on existing state. Instead:
countSeconds = () => {
this.setState(({secondsElapsed}) => {secondsElapsed: secondsElapsed + 1});
};
Similarly in captureTime:
captureTime = (startOrStop) => {
if (startOrStop) { // *** There's no reason for `=== true`
this.setState(({timer, secondsElapsed}) => {timer: {...timer, screenshotStart: secondsElapsed}});
} else { // *** Unless `startOrStop` may be missing or something, no need for `if` or `=== false`.
this.setState(({timer, secondsElapsed}) => {
const timingArray = [...timer.timingArray, secondsElapsed - timer.screenshotStart];
const update = {
timer: {
...timer,
screenshotEnd: secondsElapsed,
timingArray,
averageTimePerUnit: timingArray.reduce((a,b) => a + b, 0)
}
};
});
}
};
Side note: Your copyState function does a shallow state copy. So if you modify any properties on the objects it contains, you'll be directly modifying state, which you mustn't do in React.

setState hooks overwrite the state with a new object always... that is their correct behavior.
you need to use a function within setState. not just pass in an object.
setState((prevState,prevProps)=>{
//logic to make a new object that you will return ... copy properties from prevState as needed.
//something like const newState = {...prevState} //iffy myself on exact syntax
return newState
})

Your getStateCopy is only shallow cloning the existing state - anything nested is not cloned. To illustrate:
const getStateCopy = () => Object.assign({}, state);
const state = {
foo: 'bar',
arr: [1, 2]
};
const shallowCopy = getStateCopy();
shallowCopy.foo = 'newFoo';
shallowCopy.arr.push(3);
console.log(state);
Either deep clone the state first instead, or use spread to add in the new properties you want:
countSeconds = () => {
this.setState({
...this.state,
secondsElapsed: this.state.secondsElapsed + 1
});
}
captureTime = (startOrStop) => {
if (startOrStop === true) {
this.setState({ ...this.state, timer: { ...this.timer, screenshotStart: this.state.secondsElapsed } });
} else if (startOrStop === false) {
const newTimingValue = this.state.secondsElapsed - this.state.timer.screenshotStart;
const newTimingArray = [...this.state.timer.timingArray, newTimingValue];
this.setState({
...this.state,
timer: {
...this.timer,
screenshotEnd: this.state.secondsElapsed,
timingArray: newTimingArray,
averageTimePerUnit: newTimingArray.reduce((a, b) => a + b, 0) / newTimingArray.length,
},
});
}
}
If captureTime is always called with either true or false, you can make things look a bit cleaner with:
captureTime = (startOrStop) => {
if (startOrStop) {
this.setState({ ...this.state, timer: { ...this.timer, screenshotStart: this.state.secondsElapsed } });
return;
}
const newTimingValue = this.state.secondsElapsed - this.state.timer.screenshotStart;
const newTimingArray = [...this.state.timer.timingArray, newTimingValue];
// etc

Related

React, conditional rendering wont register change in object

The if statement in canBookSlot() is only checked once for some reason. The second time canBookSlot() is triggered, the userDetailsObj.canBook should be 0 after running updateUser(). And according to the console log it is the case, but the if statement still runs, why?
let userDetailsString = localStorage.getItem("userDetails");
let userDetailsObj = JSON.parse(userDetailsString);
const updateUser = () => {
userDetailsObj["hasBooked"] = 1;
userDetailsObj["canBook"] = 0;
};
const canBookSlot = (id) => {
if (userDetailsObj.canBook != 0) { // always true
updateUser();
Axios.post("http://localhost:3001/api/book/week1/ex", {
room: userDetailsObj.room,
id: id.id + 1,
}).then(() => updateData());
} else {
console.log("already booked");
}
};
After each render userDetailsObj will take that value from localStorage. That's how every variable inside a component which isn't a state made with useState hook, or a ref made with useRef hook behaves. You can fix your problem this by using a state, like so:
const [userDetails, setUserDetails] = useState(JSON.parse(localStorage.getItem("userDetails")));
const updateUser = () => {
const newUserDetails = { ...userDetailsObj, hasBooked: 1, canBook: 0 };
setUserDetails(newUserDetails);
localStorage.setItem("userDetails", JSON.stringify(newUserDetails));
};
const canBookSlot = (id) => {
if (userDetails.canBook != 0) {
//Always true
updateUser();
Axios.post("http://localhost:3001/api/book/week1/ex", {
room: userDetailsObj.room,
id: id.id + 1,
}).then(() => updateData());
} else {
console.log("already booked");
}
};
Can you clarify where this code runs? Are you using a class component or functional component? Would you mind sharing the entire component? If it is doing what I think it is doing, the let userDetailsString = localStorage.getItem("userDetails"); is running every render which means on every render, it grabs the value in localStorage and uses that, rather than using your object stored in userDetailsObj.
If you are using functional components, you could fix this by using state.
let userDetailsString = localStorage.getItem("userDetails");
let [userDetailsObj, updateUserDetailObj] = useState(JSON.parse(userDetailsString));
const updateUser = () => {
let u = { ...userDetailsObj,
hasBooked: 1,
canBook: 0,
}
updateUserDetailObj(u);
};
If you are using class Components, let me know and I'll update it with that option.

Updating an object property within an array of objects in React

I am on the newer side of React and trying to change the state of an object in an array. Currently, I am pulling the object out of the array, changing the property in that object, then adding the new object to the state again. Problem being that it sends the object to the back of the list and reorders my checkbox inputs.
const handleChange = (e) => {
if (e.target.type === "checkbox") {
// Get the role from the current state
const roleToChange = input.roles.find(
(role) => Number(role.id) === Number(e.target.id)
);
// Change checked state to opposite of current state
const changedRole = { ...roleToChange, checked: !roleToChange.checked };
// Get every role except the one that was changed
const newRoles = input.roles.filter(
(role) => Number(role.id) !== Number(e.target.id)
);
// Update the role in the state
setInput((prevState) => {
return { ...prevState, roles: [...newRoles, changedRole] };
});
}
Can I update the object in the array in-place so this doesn't happen?
Don't .filter - .map instead, and return the changed object in case the ID matches, so the new object gets put at the same place in the new array as it was originally.
const handleChange = (e) => {
if (e.target.type !== "checkbox") {
return;
};
const newRoles = input.roles.map((role) =>
Number(role.id) !== Number(e.target.id)
? role
: { ...role, checked: !role.checked }
);
setInput((prevState) => {
return {
...prevState,
roles: newRoles
};
});
}
Unless the state is updated synchronously before this, which sounds a bit unlikely (but not impossible), you can also probably use setInput({ ...input, roles: newRules }) instead of the callback.

How to correctly update state in array using react?

In a previous question, I was given an answer on how to update an array, which was achieved in the following way:
onClick(obj, index) {
if (data.chosenBets[index]) {
// Remove object.
data.chosenBets.splice(index, 1);
} else {
// Add object.
data.chosenBets.splice(index, 0, obj);
}
}
This does not trigger a re-render in my UI. How do I update the array (in the same way as above) while triggering a re-render?
Just mutating a state won't trigger re-render. You need to call setState() function:
// class component
onClick = () => {
// update state
this.setState(newState);
}
// functional component
...
const [ state, setState ] = useState();
...
setState(newState);
Also, it's quite important to perform immutable state updates since React relies on refs usually (especially, when using memo() or PureComponent). So, it's better to create new instance of array with the same items.
onClick(obj, index) {
let newData;
if (data.chosenBets[index]) {
newData = data.slice();
newData.chosenBets.splice(index, 1);
} else {
newData = [ obj, ...data ];
}
setState(newData);
}
And you always can use some libraties for immutable update like immer, object-path-immutable etc.
Try avoiding impure functions when writing react codes. Here, splice is an impure method. I would recommend using the below code:
onClick(obj, index) {
if (this.state.data.chosenBets[index]) {
// Remove object.
const data = {
...this.state.data,
chosenBets: this.state.data.chosenBets.filter((cBet, i) => i !== index)
};
this.setState({ data });
} else {
// Add object.
const data = {
...this.state.data,
chosenBets: [ ...this.state.data.chosenBets, obj ]
};
this.setState({ data });
}
}
I am assuming you have that array already saved in your state. Then you can do something like this:
onClick = (idx) => {
let arr = [...this.state.arrayToModify];
arr.splice(idx,1);
this.setState({ arrayToModify: arr });
}
Hope this helps!
I needed to make a copy of the array:
let arr = appState.chosenBets
arr.splice(index, 1)
appState.chosenBets = arr
Rather than simply doing
data.chosenBets.splice(index, 1);

Testing React Components setState overload which takes a function

I am trying to test a React component which uses one of the overloads for setState, but am unsure how to assert the call correctly. An example component would be:
class CounterComponent extends React.Component {
updateCounter() {
this.setState((state) => {
return {
counterValue: state.counterValue + 1
};
});
}
}
The assumption here is that this method will be called asyncronously, so cannot rely on the current state, outwith the call to setState (as it may change before setState executes). Can anyone suggest how you would assert this call? The following test fails as it is simply comparing the function names.
it("Should call setState with the expected parameters", () => {
const component = new CounterComponent();
component.setState = jest.fn(() => {});
component.state = { counterValue: 10 };
component.updateCounter();
const anonymous = (state) => {
return {
counterValue: state.counterValue + 1
};
};
//expect(component.setState).toHaveBeenCalledWith({ counterValue: 11 });
expect(component.setState).toHaveBeenCalledWith(anonymous);
});
Edit: Given yohai's response below, i will add some further context as I feel i may have over simplified the problem however i do not want to re-write the entire question for clarity.
In my actual component, the state value being edited is not a simple number, it is an array of objects with the structure:
{ isSaving: false, hasError: false, errorMessage: ''}
and a few other properties. When the user clicks save, an async action is fired for each item in the array, and then the corresponding entry is updated when that action returns or is rejected. As an example, the save method would look like this:
onSave() {
const { myItems } = this.state;
myItems.forEach(item => {
api.DoStuff(item)
.then(response => this.handleSuccess(response, item))
.catch(error => this.handleError(error, item));
});
}
The handle success and error methods just update the object and call replaceItem:
handleSuccess(response, item) {
const updated = Object.assign({}, item, { hasSaved: true });
this.replaceItem(updated);
}
handleError(error, item) {
const updated = Object.assign({}, item, { hasError: true });
this.replaceItem(updated);
}
And replaceItem then replaces the item in the array:
replaceItem(updatedItem) {
this.setState((state) => {
const { myItems } = state;
const working = [...myItems];
const itemToReplace = working.find(x => x.id == updatedItem.id);
if (itemToReplace) {
working.splice(working.indexOf(itemToReplace), 1, updatedItem);
};
return {
myItems: working
};
});
}
replaceItem is the method I am trying to test, and am trying to validate that it calls setState with the correct overload and a function which correctly updated the state.
My answer below details how I have solved this for myself,but comments and answers are welcome =)
#Vallerii: Testing the resulting state does seem a simpler way, however if i do, there is no way for the test to know that the method is not doing this:
replaceItem(updatedItem) {
const { myItems } = state;
const working = [...myItems];
const itemToReplace = working.find(x => x.id == updatedItem.id);
if (itemToReplace) {
working.splice(working.indexOf(itemToReplace), 1, updatedItem);
};
this.setState({ myItems: working });
}
When replaceItem does not use the correct overload for setState, this code fails when called repeatedly as (I assume) react is batching updates and the state this version uses is stale.
I think you should test something a little bit different and it will look somthing like this (I'm using enzyme):
import React from 'react'
import { mount } from 'enzyme'
import CounterComponent from './CounterComponent'
it("Should increase state by one", () => {
const component = mount(<CounterComponent />)
const counter = 10;
component.setState({ counter });
component.instance().updateCounter();
expect(component.state().counter).toEqual(counter + 1);
});
I have come up with a solution to this after some further thought. I am not sure it is the best solution, but given that the updateCounter method in the example above passes a function into the setState call, I can simply get a reference to that function, execute it with a known state and check the return value is correct.
The resulting test looks like this:
it("Should call setState with the expected parameters", () => {
let updateStateFunction = null;
const component = new CounterComponent();
component.setState = jest.fn((func) => { updateStateFunction = func;});
component.updateCounter();
const originalState = { counterValue: 10 };
const expectedState = { counterValue: 11};
expect(component.setState).toHaveBeenCalled();
expect(updateStateFunction(originalState)).toEqual(expectedState);
});

react state extend multilevel object not working

state default values
state = {
moveType: {
value: 0,
open: false,
completed: false
}
};
// callback to update new state
let step = 'moveType';
let val = 3; // new value
let newObj = { ...this.state[step], value: val };
console.log(newObj);
this.setState({[step]: newObj }, function () {console.log(this.state);});
console.log(newObj) shows new values proper, but this.state still shows old values.. can you tell me what i'm doing wrong?
Setting state in react is pretty sensitive thing to do.
The best practices I've used to is always control object deep merge manually and use this.setState(state => { ... return new state; }) type of call, like in this example:
this.setState(state => ({
...state,
[step]: { ...(state[step] || {}), ...newObj },
}), () => console.log(this.state));
SNIPPET UPDATE start
[step]: { ...state[step], ...newObj }
Changed to:
[step]: { ...(state[step] || {}), ...newObj }
To deal correctly with cases, when state does not have this step key yet
SNIPPET UPDATE end
Thing is, that when you use this.state (in let newObj = { ...this.state[step]), it might have an outdated value, due to some pending (not merged yet) changes to the state, that you've called just couple of milliseconds ago.
Thus I recommend to use callback approach: this.setState(state => { ... use state and return new state;}) which guarantees that the state you use has latest value

Categories