Proper Component Tree for Array within Array Rerender on Change - javascript

Im wracking my brain trying to understand the component structure here that I should employ. I feel like this is something that I absolutely want to get correctly because going forward it's important to understand how a ReactJS application should look and how to correctly separate the concerns. I know it is an opinionated front but how I am currently doing it is not correct, and I was looking for some insight.
Data Model: This is a large array of Recipes, each contains another array of ingredients. I want to allow the user to "tick off" (the ingredient is removed from the array) the ingredients as they buy them/acquire them.
[
...
{
"title": "Recipe 1"
"ingredients": [
{ "title": "Flour", "measurement": "300g" },
{ "title": "Sesame Seeds", "measurement": "1 Tblsp" }
]
},
...
]
Current Psuedo Component Tree:
// RecipeList is my "HOC" (higher-order component) and contains all the functions/data required for the rest of the tree, and simply passes them down as props. This is `connect()`ed to the store here.
<RecipeList>
// Map on the highest level array to display a <Recipe> component for each
<Recipe data={recipe[mappedIndex]}>
// Map on each ingredient to create a list item for each
<RecipeIngredient data={recipe[mappedIndex].ingredient[mappedIndex]>
<IngredientCheckBox onChange={ //remove this ingredient from the array } />
</RecipeIngredient>
</Recipe>
</RecipeList>
All is well with the above, the data is displayed exactly how I would expect it to.
However, and this is the main issue, when it comes to onChange I call an action, COMPLETE_INGREDIENT which basically removes it from the ingredients array (I see it in action, using redux-logger the next-state does not contain it).
Unfortunately, my components don't rerender. It is no longer in the array but is still displayed on screen. I understand this may one of the following reasons:
connect() only shallow compares the states so doesn't trigger a rerender because it is a value in an array, inside of a property of an object in an array.
My connect() is too far from the action, and should be reconnect()ed at a deeper component level, say the <Recipe> component and only attach it to a part of the store that it cares about (could even be the <RecipeIngredient>).
My reducer is not modifying the state in an immutable way. This is the one I have spent most time on, however even using slice() and the sorts, I still can't get it to re-render
Edit: My reducer for action COMPLETE_INGREDIENT. I understand this may be the issue, as it is directly mutating the state. What would be the correct way for such a deep change to the state?
case COMPLETE_INGREDIENT:
// state is from a level above the recipe's, that contains a timestamp etc
state.recipes[action.payload.recipeIndex].ingredients.splice(action.payload.ingredientIndex, 1)
return Object.assign({
...state
})

Edit: My reducer for action COMPLETE_INGREDIENT. I understand this may
be the issue, as it is directly mutating the state. What would be the
correct way for such a deep change to the state?
Yep, you are mutating state with that Object.assign. As a first argument it should have new Object to copy values to and return:
return Object.assign({}, {
...state
})
Based on your code I've created updating function I would probably create:
case COMPLETE_INGREDIENT: {
const { recipeIndex, ingregientIndex } = action.payload;
const recipesListCopy = [...state.recipes];
const recipeCopy = {
...recipesListCopy[recipeIndex],
ingredients: recipesListCopy[recipeIndex].ingredients.filter(
(e, index) => index !== ingredientIndex
)
};
recipesListCopy[recipeIndex] = recipeCopy;
return {
...state,
recipes: recipesListCopy
};
}
Edit:
based on your comment - "remove the recipe from the top level recipe array if the ingredients array is now empty"
case COMPLETE_INGREDIENT: {
const { recipeIndex, ingregientIndex } = action.payload;
const recipesListCopy = [...state.recipes];
const updatedIngredientsList = recipesListCopy[recipeIndex].ingredients.filter(
(e, index) => index !== ingredientIndex
);
if(updatedIngredientsList.length > 0) {
// update ingredients
const recipeCopy = {
...recipesListCopy[recipeIndex],
ingredients: updatedIngredientsList
};
recipesListCopy[recipeIndex] = recipeCopy;
} else {
// remove recipe because no igridients
recipesListCopy.splice(recipeIndex, 1);
}
return {
...state,
recipes: recipesListCopy
};
}

Problem here is in mutating existing state
case COMPLETE_INGREDIENT:
// state is from a level above the recipe's, that contains a timestamp etc
state.recipes[action.payload.recipeIndex].ingredients.splice(action.payload.ingredientIndex, 1)
return Object.assign({
...state
})
Method splice changes existing array, but you need to create new array. Here is example
const obj = { a: { b: 5 } };
const copyObj = { ...obj };
copyObj.a.b = 1;
console.log(obj.a.b); // 1
console.log(copyObj.a.b); // 1
You have copied state object but ingredients array stays the same. So you need to copy array.
case COMPLETE_INGREDIENT:
return {
...state,
recipes: state.recipes.map(
(item, index) =>
index === action.payload.recipeIndex
? { ...item, ingredients: item.ingredients.filter((item, index) => index !== action.payload.ingredientIndex) }
: item
),
};

Related

Updating an array of objects with setTimeout with react and state

I have an array of objects, and each object has a false property value by default:
const [items, setItems] = useState([ { key: 1, has_x: false }, { key: 2, has_x: false }, { key: 3, has_x: false } ]);
Which is passed down to child components:
<Item key={item.key} hasX={item.has_x} />
In the parent component, I have a "Check All" button:
<button onClick={handleCheckAll}>Check All</button>
Which would loop through every item and modify item.has_x to true.
In the child components, there's also a "Check" button, but instead of checking all items, it just checks and sets that one specific item has_x to true.
I think I would know how to do each one. For the "Check All" function, I'd create a shadow copy, set the value, and then once the loop is done, set the state. For the child button, I really just need a useState there for it.
However, I am stuck on the "Check All" button because I'd like to have ui updates as has_x for each item gets updated and a setTimeout, as the actual functionality of check all will be expensive and needs about 200-300ms wait time for each check. Imagine a button's text changes to a checkmark as the function loops through each item.
Here's my attempt but the UI doesn't get updated and I only am setting the state once it's done:
const checkAllItems = () => {
let temp = [...items];
temp.map((item, index) => {
setTimeout(() => {
let tempEl = {...tempState[index]};
if (tempEl) item.has_x = true;
}, index * 200)
})
setItems(temp)
}
My only idea of how to do this is to use refs and loop through the refs to run a function in each child component that will do this, but I feel like there's a correct way to go about this. How is it possible?
I can't imagine needing a timeout for updating state. I am certain if you tweak your check-all items code a bit you won't need the timeout. You can use a functional state update to enqueue the state updates and correctly update from the previous state instead of the state value closed over in callback scope.
const checkAllItems = () => {
setItems(items => items.map(item => ({
...item,
has_x: true,
})));
};
This shallow copies the previous items state into a new array, and then you also shallow copy each item element and update the has_x property.
Update
If you are making backend API requests per checked update in items then I suggest doing this in a useEffect lifecycle hook. Loop over each item and enqueue the network request in a setTimeout. It's basically splitting out the timeout logic (you had previously) from the state update.
useEffect(() => {
items.forEach((item, index) => {
setTimeout(() => {
// make API request with `item`
}, index * 200);
});
}, [items]);
/update
I also don't recommend the child components to also have any "checked" state, you want a single source of truth as to the checked status of your items array. Pass a callback to the children for them to update their checked status in the parent component's state.
It could look something like this:
const updateStatus = (key, value) => {
setItems(items => items.map(item => item.key === key
? {
...item,
has_x: value,
}
: item
));
};
...
<Item key={item.key} hasX={item.has_x} updateX={updateStatus} />

How to compare props in ComponentDidUpdate for large data structure connected with Redux

I've been working with new lifecycles of React v16. It works great when we compare only a single key. But when it comes to compare a large data structures like an Arrays of objects, the deep comparison will become very costly.
I have use case like this in which I have an array ob objects stored in redux,
const readings =[
{
id: ...,
name: ...',
unit:...,
value: ...,
timestamp: ...,
active: true,
},
...
]
Whenever active state of any objects get changed I dispatch an action to update redux state to be same for all connected components with that reducer.
class Readings extends Component {
state = {
readings:[],
};
static getDerivedStateFromProps(nextProps, prevState) {
if ( // comparsion of readings array with prevState) {
return {
readings: nextProps.readings,
};
}
return null;
}
componentDidUpdate(prevProps) {
if ( // comparsion of readings array with prevState) {
// perform an operation here to manipulate new props and setState to re render the comp
// this causes infinite loop
}
}
render() {
...
}
}
const mapStateToProps = state => ({
readings: state.readings.readings,
});
export default connect(
mapStateToProps,
)(Readings));
How can I avoid infinite loop on setState in componentDidUpdate, I don't want to do deep comparison of readings array. Is there a better solution to handle this case?
Your suggestions will be highly appreciated.
Ideally, you make immutable changes to your reducer and keep the
reducer state level low.
So if your array consists of many objects and you need to dispatch based on some attribute change, you should replace the whole readings array using spread operator or using some immutable library e.g immutablejs. Then in your componentDidupdate you can have something like :
componentDidUpdate(prevProps) {
const {
readings,
} = this.props
const {
readings: prevReadings,
} = prevProps
if (readings !== prevReadings) {
//dispatch something
}
}
Thanks feedbacks are welcome.
First read another answer, If that didn't work for you, then:
Make sure you're comparing two arrays with :
componentDidUpdate(prevProps){
if(JSON.stringify(prevProps.todos) !== JSON.stringify(this.props.todos){...}
}
Make sure you are changing the passed prop (state in parent) after deep cloning it:
let newtodos = JSON.parse(JSON.stringify(this.state.todos));
newtodos[0].text = 'changed text';
this.setState({ todos : newtodos });
(Note that Shallow clone doesn't work, since that way you change the objects directly)

pushed items to array seem to be reactive to old object

I am facing an issue where after I push an object to an array it will be reactive to changes.
// actions.js
export const addToCart = ({ commit }) => {
commit('addToCart'); // properly commits to change the state
setTimeout(function () {
commit('resetToppings');
}, 10000);
};
// mutations.js
export const addToCart = (state) => {
state.cart.push(state.orderItem);
};
export const resetToppings = (state) => {
state.orderItem.toppings = [];
};
// state.js
cart: [],
orderItem: {
quantity: 1,
toppings: [],
},
The orderItem gets properly pushed to the cart, but 10 seconds later when I resetToppings, it resets the toppings inside the cart as well as in the orderItem.
How can I make sure that resetToppings does not mutate anything inside cart?
When you push state.orderItem you add a reference to it in the array. So when state.orderItem changes, the element inside the array changes, because it (the element inside the array) is actually still pointing to the same (state.orderItem) object.
You can push a shallow clone of the orderItem object instead:
// mutations.js
export const addToCart = (state) => {
state.cart.push({...state.orderItem});
};
This way what is added to the array is a different object.
Note: you can do:
state.cart.push({...state.orderItem});
But this will only work if you never remove/add elements from/to the toppings array directly after the addToCart is called. That is, if you call resetToppings before adding new elements to toppings (which will work because resetToppings assigns a new array).
If that's not always the case, I mean, if you sometimes edit the toppings array directly after the addToCart is called, you might want to clone it as well:
state.cart.push(Object.assign({...state.orderItem}, {toppings: [...state.orderItem.toppings]}});

Update state with two functions, doesn't update component correctly

I have a component with two functions which should update state object:
class Categories extends Component {
constructor(props) {
super(props);
this.state = {
data: [],
categoryData: [],
objects: [],
object:[],
};
}
componentDidMount() {
this.setState({
data:data.Dluga,
categoryData: data.Dluga.basic,
objects:data,
})
}
changeCategory(event) {
event.preventDefault();
this.setState({
categoryData: this.state.data[(event.currentTarget.textContent).split(' ')[1]],
});
}
changeObject(event) {
event.preventDefault();
const objectOne = Object.assign({}, this.state.objects[event.currentTarget.parentElement.parentElement.children[0].children[0].value]);
this.setState({
objects: this.state.objects,
object:objectOne,
});
};
render() {
return (
<div className='categories'>
<SelectObejct onValueChange={this.changeObject}/>
<ul>
{Object.keys(this.state.data).map((item) => {
return (
<li className='category' key={item}
onClick={this.changeCategory.bind(this)}>
<span className='category-item'> {item}</span>
</li>
)})
}
</ul>
<div>
<CategoryData categoryData={this.state.categoryData}/>
</div>
</div>
);
}
}
When I update state with changeObject I have in state object two properties: objects and object, but initially it was 4 properties... Next when I update state with changeCategory I have initial properties from componentDidMount and updated categoryData but object is empty... I can't update state in one function because it's two onClick elements. What should I do to update state correctly?
The primary thing you're doing incorrectly is updating state based on existing state without using the callback version of setState. State updates can be asynchronous, and can be combined (batched). Any time you're setting state derived from the current state, you must use the callback form. E.g.:
changeCategory(event) {
event.preventDefault();
this.setState(prevState = > {
return {
categoryData: prevState.data[(event.currentTarget.textContent).split(' ')[1]]
};
});
}
Note that we're passing it a function, which will get called later (only a tiny bit later, but later), and will get the then-current state passed to it as a parameter; and we return the new state as a return value.
When I update state with changeObject I have in state object two properties: objects and object, but initially it was 4 properties...
That's absolutely normal. It's common to only specify a subset of your state properties when calling setState. In fact, changeObject should be:
changeObject(event) {
event.preventDefault();
this.setState(prevState => {
const objectOne = Object.assign({}, prevState.objects[event.currentTarget.parentElement.parentElement.children[0].children[0].value]);
return { object: objectOne };
});
}
Note that I didn't specify objects: prevState.objects. There's no reason to if you're not changing it.
Next when I update state with changeCategory I have initial properties from componentDidMount and updated categoryData but object is empty.
object will only be empty (whatever "empty" means) if you set it to that at some point. I suspect resolving the above will resolve this issue, but if not, and if you can't figure it out with further debugging, I suggest posting a new question with an [mcve] demonstrating that problem (you can do a runnable one with Stack Snippets; here's how).

Filtering performance with large lists

I have a list of ~5000 elements and I would like to filter it by searchPhrase which is given by user.
Usually user types first letter of filtering phrase, then second and then third.
For example user types 'a', then 'ab', then 'abc'.
I'm trying to use reactjs/reselect library to improve filtering performance.
I have used this lib as in Example on readme:
Example
I have changed this method:
export const getVisibleTodos = createSelector(
[ getVisibilityFilter, getTodos ],
(visibilityFilter, todos) => {
switch (visibilityFilter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
}
)
to that implementation:
export const getVisibleTodos = createSelector(
[getSearchPhrase, getTodos],
(searchPhrase, todos) => {
return todos.filter((x) => {
return x.firstName.indexOf(searchPhrase) >= 0;
});
}
)
I noticed that each time when user types next letter todos.length property is the same.
Shouldn't todos.length be shorter when searchPhrase is longer?
I think that performance without reactjs/reselect is the same.
Is that possibility to filter shorter todos list when previous searchPhrase is substring of actual searchPhrase?
That's not how reselect improves performance: the actual filtering of the todos takes exactly the same time with or without reselect.
What reselect does for you is to memoize the filtering: as long as getSearchPhrase and getTodos return the same value (as defined by === equality), calling getVisibleTodos multiple times does the filtering only once.
This is important in a complex app, where you have many unrelated changes to redux state: without reselect any change to the state would result in all selectors being run again, even if that part of the state is unchanged.

Categories

Resources