React setter function not updating state as expected - reactjs

I am somewhat new to React and I am running into an issue and I was hoping someone will be willing to help me understand why my method is not working.
I have this state:
const [beers, setBeers] = useState([
{
id: 8759,
uid: "8c5f86a9-87bf-41fa-bc7f-044a9faf10be",
brand: "Budweiser",
name: "Westmalle Trappist Tripel",
style: "Fruit Beer",
hop: "Liberty",
yeast: "1056 - American Ale",
malts: "Special roast",
ibu: "22 IBU",
alcohol: "7.5%",
blg: "7.7°Blg",
bought: false
},
{
id: 3459,
uid: "7fa04e27-0b6b-4053-a26b-c0b1782d31c3",
brand: "Kirin",
name: "Hercules Double IPA",
style: "Amber Hybrid Beer",
hop: "Nugget",
yeast: "2000 - Budvar Lager",
malts: "Vienna",
ibu: "18 IBU",
alcohol: "9.4%",
blg: "7.5°Blg",
bought: true
}]
I am rendering the beers with a map function and I have some jsx that calls a handleClick function
<button onClick={() => handleClick(beer.id)}>
{beer.bought ? "restock" : "buy"}
</button>
this is the function being called:
const handleClick = (id) => {
setBeers((currentBeers) =>
currentBeers.map((beer) => {
if (beer.id === id) {
beer.bought = !beer.bought;
console.log(beer);
}
return beer;
})
);
};
I wanted to use an updater function to update the state, I am directly mapping inside the setter function and since map returns a new array, I thought everything would work correctly but in fact, it doesn't. It works only on the first button click and after that it stops updating the value.
I noticed that if I use this method:
const handleClick = (id) => {
const newbeers = beers.map((beer) => {
if (beer.id === id) {
beer.bought = !beer.bought;
}
return beer;
});
setBeers(newbeers);
};
Then everything works as expected.
Can someone help me understand why my first method isn't working?

OK, I think I have figured it out. The difference between my sandbox and your sandbox is the inclusion of <StrictMode> in the Index file. Removing this fixes the issue, but is not the correct solution. So I dug a little deeper.
What we all missed was that in your code you were modifying the previous state object that is passed in. You should instead be creating a new beer object and then modifying that. So this code works (I hope):
setBeers((currentBeers) =>
currentBeers.map((currentBeer) => { // changed beer to currentBeer
const beer = {...currentBeer};
if (beer.id === id) {
beer.bought = !beer.bought;
}
return beer;
)
});
I hope that this helps.

react does not deeply compares the object in the state. Since you map over beers and just change a property, they are the same for react and no rerender will happen.
You need to set the state with a cloned object.
e.g.:
import {cloneDeep} from 'lodash';
...
setBeers(
cloneDeep(currentBeers.map((beer) => {
if (beer.id === id) {
beer.bought = !beer.bought;
console.log(beer);
}
return beer;
})
)
);

Related

React Hooks - keep arguments reference in state

I created a hook to use a confirm dialog, this hook provides the properties to the component to use them like this:
const { setIsDialogOpen, dialogProps } = useConfirmDialog({
title: "Are you sure you want to delete this group?",
text: "This process is not reversible.",
buttons: {
confirm: {
onPress: onDeleteGroup,
},
},
width: "360px",
});
<ConfirmDialog {...dialogProps} />
This works fine, but also I want to give the option to change these properties whenever is needed without declaring extra states in the component where is used and in order to achieve this what I did was to save these properties in a state inside the hook and this way provide another function to change them if needed before showing the dialog:
interface IState {
isDialogOpen: boolean;
dialogProps: TDialogProps;
}
export const useConfirmDialog = (props?: TDialogProps) => {
const [state, setState] = useState<IState>({
isDialogOpen: false,
dialogProps: {
...props,
},
});
const setIsDialogOpen = (isOpen = true) => {
setState((prevState) => ({
...prevState,
isDialogOpen: isOpen,
}));
};
// Change dialog props optionally before showing it
const showConfirmDialog = (dialogProps?: TDialogProps) => {
if (dialogProps) {
const updatedProps = { ...state.dialogProps, ...dialogProps };
setState((prevState) => ({
...prevState,
dialogProps: updatedProps,
}));
}
setIsDialogOpen(true);
};
return {
setIsDialogOpen,
showConfirmDialog,
dialogProps: {
isOpen: state.isDialogOpen,
onClose: () => setIsDialogOpen(false),
...state.dialogProps,
},
};
};
But the problem here is the following:
Arguments are passed by reference so if I pass a function to the button (i.e onDeleteGroup) i will keep the function updated to its latest state to perform the correct deletion if a group id changes inside of it.
But as I'm saving the properties inside a state the reference is lost and now I only have the function with the state which it was declared at the beginning.
I tried to add an useEffect to update the hook state when arguments change but this is causing an infinite re render:
useEffect(() => {
setState((prevState) => ({
...prevState,
dialogProps: props || {},
}));
}, [props]);
I know I can call showConfirmDialog and pass the function to update the state with the latest function state but I'm looking for a way to just call the hook, declare the props and not touch the dialog props if isn't needed.
Any answer is welcome, thank you for reading.
You should really consider not doing this, this is not a good coding pattern, this unnecessarily complicates your hook and can cause hard to debug problems. Also this goes against the "single source of truth" principle. I mean a situation like the following
const Component = ({title}: {title?: string}) => {
const {showConfirmDialog} = useConfirmDialog({
title,
// ...
})
useEffect(() => {
// Here you expect the title to be "title"
if(something) showConfirmDialog()
}, [])
useEffect(() => {
// Here you expect the title to be "Foo bar?"
if(somethingElse) showConfirmDialog({title: 'Foo bar?'})
}, [])
// But if the second dialog is opened, then the first, the title will be
// "Foo bar?" in both cases
}
So please think twice before implementing this, sometimes it's better to write a little more code but it will save you a lot debugging.
As for the answer, I would store the props in a ref and update them on every render somehow like this
/** Assign properties from obj2 to obj1 that are not already equal */
const assignChanged = <T extends Record<string, unknown>>(obj1: T, obj2: Partial<T>, deleteExcess = true): T => {
if(obj1 === obj2) return obj1
const result = {...obj1}
Object.keys(obj2).forEach(key => {
if(obj1[key] !== obj2[key]) {
result[key] = obj2[key]
}
})
if(deleteExcess) {
// Remove properties that are not present on obj2 but present on obj1
Object.keys(obj1).forEach(key => {
if(!obj2.hasOwnProperty(key)) delete result[key]
})
}
return result
}
const useConfirmDialog = (props) => {
const localProps = useRef(props)
localProps.current = assignChanged(localProps.current, props)
const showConfirmDialog = (changedProps?: Partial<TDialogProps>) => {
localProps.current = assignChanged(localProps.current, changedProps, false)
// ...
}
// ...
}
This is in case you have some optional properties in TDialogProps and you want to accept Partial properties in showConfirmDialog. If this is not the case, you could simplify the logic a little by removing this deleteExcess part.
You see that it greatly complicates your code, and adds a performance overhead (although it's insignificant, considering you only have 4-5 fields in your dialog props), so I really recommend against doing this and just letting the caller of useConfirmDialog have its own state that it can change. Or maybe you could remove props from useConfirmDialog in the first place and force the user to always pass them to showConfirmDialog, although in this case this hook becomes kinda useless. Maybe you don't need this hook at all, if it only contains the logic that you have actually shown in the answer? It seems like pretty much the only thing it does is setting isDialogOpen to true/false. Whatever, it's your choice, but I think it's not the best idea

Issue with state update approach for nested objects

Major EDIT
I have quite huge object which is 3 level deep. I use it as a template to generate components on the page and to store the values which later are utilized, eg:
obj =
{
"group": {
"subgroup1": {
"value": {
"type": "c",
"values": []
},
"fields_information": {
"component_type": "table",
"table_headers": [
"label",
"size"
],
}
},
"subgroup2": {
"value": {
"type": "c",
"values": []
},
"fields_information": {
"component_type": "table",
"table_headers": [
"label",
"size"
],
}
},
},
}
Thanks to this I can dynamically generate view which is, as a template, stored in DB.
I'm struggling with 2 things. Firstly, updating values basing on user input for textbox, checkboxes and similar.
I'm doing it this way:
const updateObj = (group, subgroup, value) => {
let tempObj = {...obj}
tempObj[group][subgroup].value.value = value
toggleObj(tempObj)
}
I know that the spread operator is not in fact doing deep copy. However it allows me to work on the object and save it later. Is that an issue? Do I have to cloneDeep or it is just fine? Could cloneDeep impact performance?
Second case is described below
export const ObjectContext = React.createContext({
obj: {},
toggleObj: () => {},
});
export const Parent = (props) => {
const [obj, toggleObj] = useState()
const value = {obj, toggleObj}
return (
<FormCreator />
)
}
const FormCreator = ({ catalog }) => {
const {obj, toggleObj} = React.useContext(ObjectContext)
return (<>
{Object.keys(obj).map((sectionName, sectionIdx) => {
const objFieldsInformation = sectionContent[keyName].fields_information
const objValue = sectionContent[keyName].value
...
if (objFieldsInformation.component_type === 'table') {
return (
<CustomTable
key={keyName + "id"}
label={objFieldsInformation.label}
headers={objFieldsInformation.table_headers}
suggestedValues={[{label: "", size: ""}, {label: "", size: ""}, {label: "", size: ""}]}
values={objValue.values}
sectionName={sectionName}
keyName={keyName}/>
)
}
...
})}
</>)
}
const CustomTable= (props) => {
const { label = "", headers = [], suggestedValues = [], values, readOnly = false, sectionName, keyName } = props
const {obj, toggleObj} = React.useContext(ObjectContext)
//this one WORKS
useEffect(() => {
if (obj[sectionName][keyName].value.type === "complex") {
let temp = {...obj}
temp[sectionName][keyName].value.values = [...suggestedValues]
toggleObj(temp)
}
}, [])
//this one DOES NOT
useEffect(() => {
if (obj[sectionName][keyName].value.type === "c") {
let temp = {...obj, [sectionName]: {...obj[sectionName], [keyName]: {...obj[sectionName][keyName], value: {...obj[sectionName][keyName].value, values: [{label: "", size: ""}, {label: "", size: ""}, {label: "", size: ""}]}}}}
toggleObj(temp)
}
}, [])
return (
//draw the array
)
}
Please refer to CustomTable component.
As on the example Object above, I have 2 CustomTables to be printed. Unfortunately, one useEffect that should work is not working properly. I'm observing, that values field is set only for the last "table" in Obj. When I'm doing shallow copy of obj, it works fine. But I'm afraid of any repercussion that might happens in future.
I'm also totally new to using createContext and maybe somehow it is the issue.
Kudos to anyone understanding that chaos :)
The main issue appears to be that you are not providing your context. What you have is literally passing the blank object and void returning function. Hence why calling it has no actual effect, but mutating the value does.
export const ObjectContext = React.createContext({
obj: {},
toggleObj: () => {},
});
export const Parent = (props) => {
const [obj, toggleObj] = useState({})
const value = {obj, toggleObj}
return (
<ObjectContext.Provider value={value}>
<FormCreator />
</ObjectContext.Provider>
)
}
Ideally you would also make this component above wrap around FormCreator and render it as props.children instead. This is to prevent the entire sub-tree being rerendered every time toggleObj is called. See the first part of this tutorial to get an idea of the typical pattern.
As to the question about mutating state, it absolutely is important to keep state immutable in React - at least, if you are using useState or some kind of reducer. Bugs arising from state mutation come up all the time on Stack Overflow, so often in fact that I recently made a codesandbox which demonstrates some of the more common ones.
I also agree with #SamuliHakoniemi that a deeply nested object like this is actually better suited to the useReducer hook, and might even go one further and suggest that a proper state management library like Redux is needed here. It will allow you to subdivide reducers to target the fragments of state which actually update, which will help with the performance cost of deeply cloning state structure if or when it becomes an actual issue.

Usestate hook with object arrays

For some reason I can't seem to find an answer to this rather simple task I believe. My challenge is that I have a usestate array which holds multiple objects depending on the user input. It can have as many as the user chooses or none. For some reason I can't get it to work right.
What I mean is that whenever I set the state (for example: setFinal(final => [...final, value]);) in any way that I've been able to come up with so far it doesn't work properly. It works fine when the user adds on click a new object to the array but deleting them and particularly coming from 1 to 0 to 1 again causes the state to be invalid. What I mean is that the last one before going to 0 doesn't get deleted and when you start to add new objects from 0 again it has already one value in state.
So, what is the right way to set state so that it works correctly no matter if you're adding or deleting objects from the state?
Hopefully I've made myself clear enough for a solution to this. It should be fairly straightforward but I don't seem to get it right and googling doesn't seem to do the trick. Thanks in advance for anyone helping me.
EDIT:
Something for clarification:
example objects:
0: Object { id: 484, data: [] }
1: Object { id: 524, data: [] }
2: Object { id: 170, data: (3) […] }
What I've tried so far in setting state:
setFinal(final => [...final, value]);
setFinal(value);
setFinal(final => value);
I would need it to work no matter whether you add or delete, delete them all and add again, it should work in all of these conditions.
Are you looking something like this? Live Demo
function App() {
const [arr, setArr] = useState({
numbers: [
{ id: 1, name: "Reactjs" },
{ id: 2, name: "Vuejs" },
{ id: 3, name: "Nodejs" }
]
});
const onclickHandler = event => {
setArr({
...arr,
numbers: [
...arr.numbers,
{
id: Math.floor(Math.random() * 100),
name: `${Math.random()} technology`
}
]
});
};
const deleteIt = item => {
let updated = arr.numbers.filter(a => a.id !== item.id);
setArr({ numbers: updated });
};
return (
<div className="App">
<button onClick={onclickHandler} value="4">
Add
</button>
<ul>
{arr.numbers.map(a => (
<li key={a.id}>
<span>
{a.id}-{a.name}
</span>
<button style={styles} onClick={() => deleteIt(a)}>
X
</button>
</li>
))}
</ul>
</div>
);
}
const styles = {
color: "white",
backgroundColor: "red"
};
You can't delete element from array via setFinal(final => [...final, value]); If value is undefined then setFinal just ignore it. Please see: Delete item from state array in react

how to map to update an objects with setState in Reactjs

I have this state:
state = {
formdata:{
name: null,
about: null,
price: null,
offerPrice:null,
playStoreUrl:null,
appStoreUrl:null ,
photo:null,
}
}
what I want: update form inside modal i used it to update products. I used new props inside componentWillReceiveProps
I did:
componentWillReceiveProps(nextProps){
let Updateproduct = nextProps.productlist.productlist.Products;
Updateproduct.map((item,i) => {
let formdata = Object.assign({}, this.state.formdata);
formdata.name = item.name
formdata.about = item.about
formdata.price = item.price
formdata.offerPrice = item.offerPrice
formdata.playStoreUrl = item.playStoreUrl
formdata.appStoreUrl = item.appStoreUrl
formdata.photo = item.photo
console.log(formdata)
this.setState({formdata})
})
}
MyProblem: this filled the objects but in the form inside modal only I saw the last product not all in modal when click to update any product it. Note:Updateproduct contains:
{
about: "about product1"
appStoreUrl: "https://itunes.apple.com/us/app/snapchat/id447188370?mt=8"
name: "p1"
offerPrice: 99.99
photo: "images/products/"
playStoreUrl: "images/products/"
price: 1000
}
{
about: "about product2"
appStoreUrl: "https://itunes.apple.com/us/app/snapchat/id447188370?mt=8"
name: "p2"
offerPrice: 99.99
photo: "images/products/"
playStoreUrl: "images/products/"
price: 2000
}
Issue is, you want to store single specific product item clicked by user in state variable, but with current code you are always storing the last product item. Also setState in loop is not a good way.
To solve the issue, store the clicked product detail in state inside onClick handler function only, instead of componentWillReceiveProps method. For that you need to bind the product id with onClick function.
Like this:
onClick={this.getProductId.bind(this, item.id)}
// click handler function
getProductId = (id) => {
let productObj = {};
let Updateproduct = this.props.productlist.productlist.Products;
Updateproduct.forEach((item,i) => {
if(item.id == id) {
productObj = {
name: item.name,
about: item.about,
price: item.price,
offerPrice: item.offerPrice,
playStoreUrl: item.playStoreUrl,
appStoreUrl: item.appStoreUrl,
photo: item.photo
};
this.setState({ formdata: productObj, id: id })
}
})
}
You’re setting your state inside the map. If you think of the map as a loop that means you’re overriding it each iteration. So you will only ever have the last one displaying.
I think that's because your setState() call is in the wrong place (and only one object at a time):
let data =[];
UpdateProduct.map((item, i) => {
let formdata = null;
// do your formdata stuff but append it as part of an array
data.push(formdata);
});
this.setState({ formdata: data });
Then I think it should get all the products.

How to get React/recompose component updated when props are changed?

I'm writing this product list component and I'm struggling with states. Each product in the list is a component itself. Everything is rendering as supposed, except the component is not updated when a prop changes. I'm using recompose's withPropsOnChange() hoping it to be triggered every time the props in shouldMapOrKeys is changed. However, that never happens.
Let me show some code:
import React from 'react'
import classNames from 'classnames'
import { compose, withPropsOnChange, withHandlers } from 'recompose'
import { addToCart } from 'utils/cart'
const Product = (props) => {
const {
product,
currentProducts,
setProducts,
addedToCart,
addToCart,
} = props
const classes = classNames({
addedToCart: addedToCart,
})
return (
<div className={ classes }>
{ product.name }
<span>$ { product.price }/yr</span>
{ addedToCart ?
<strong>Added to cart</strong> :
<a onClick={ addToCart }>Add to cart</a> }
</div>
)
}
export default compose(
withPropsOnChange([
'product',
'currentProducts',
], (props) => {
const {
product,
currentProducts,
} = props
return Object.assign({
addedToCart: currentProducts.indexOf(product.id) !== -1,
}, props)
}),
withHandlers({
addToCart: ({
product,
setProducts,
currentProducts,
addedToCart,
}) => {
return () => {
if (addedToCart) {
return
}
addToCart(product.id).then((success) => {
if (success) {
currentProducts.push(product.id)
setProducts(currentProducts)
}
})
}
},
}),
)(Product)
I don't think it's relevant but addToCart function returns a Promise. Right now, it always resolves to true.
Another clarification: currentProducts and setProducts are respectively an attribute and a method from a class (model) that holds cart data. This is also working good, not throwing exceptions or showing unexpected behaviors.
The intended behavior here is: on adding a product to cart and after updating the currentProducts list, the addedToCart prop would change its value. I can confirm that currentProducts is being updated as expected. However, this is part of the code is not reached (I've added a breakpoint to that line):
return Object.assign({
addedToCart: currentProducts.indexOf(product.id) !== -1,
}, props)
Since I've already used a similar structure for another component -- the main difference there is that one of the props I'm "listening" to is defined by withState() --, I'm wondering what I'm missing here. My first thought was the problem have been caused by the direct update of currentProducts, here:
currentProducts.push(product.id)
So I tried a different approach:
const products = [ product.id ].concat(currentProducts)
setProducts(products)
That didn't change anything during execution, though.
I'm considering using withState instead of withPropsOnChange. I guess that would work. But before moving that way, I wanted to know what I'm doing wrong here.
As I imagined, using withState helped me achieving the expected behavior. This is definitely not the answer I wanted, though. I'm anyway posting it here willing to help others facing a similar issue. I still hope to find an answer explaining why my first code didn't work in spite of it was throwing no errors.
export default compose(
withState('addedToCart', 'setAddedToCart', false),
withHandlers({
addToCart: ({
product,
setProducts,
currentProducts,
addedToCart,
}) => {
return () => {
if (addedToCart) {
return
}
addToCart(product.id).then((success) => {
if (success) {
currentProducts.push(product.id)
setProducts(currentProducts)
setAddedToCart(true)
}
})
}
},
}),
lifecycle({
componentWillReceiveProps(nextProps) {
if (this.props.currentProducts !== nextProps.currentProducts ||
this.props.product !== nextProps.product) {
nextProps.setAddedToCart(nextProps.currentProducts.indexOf(nextProps.product.id) !== -1)
}
}
}),
)(Product)
The changes here are:
Removed the withPropsOnChange, which used to handle the addedToCart "calculation";
Added withState to declare and create a setter for addedToCart;
Started to call the setAddedToCart(true) inside the addToCart handler when the product is successfully added to cart;
Added the componentWillReceiveProps event through the recompose's lifecycle to update the addedToCart when the props change.
Some of these updates were based on this answer.
I think the problem you are facing is due to the return value for withPropsOnChange. You just need to do:
withPropsOnChange([
'product',
'currentProducts',
], ({
product,
currentProducts,
}) => ({
addedToCart: currentProducts.indexOf(product.id) !== -1,
})
)
As it happens with withProps, withPropsOnChange will automatically merge your returned object into props. No need of Object.assign().
Reference: https://github.com/acdlite/recompose/blob/master/docs/API.md#withpropsonchange
p.s.: I would also replace the condition to be currentProducts.includes(product.id) if you can. It's more explicit.

Resources