How to organise my nested object for better state management? - reactjs

This is more of an organisation than technical question. I think I may be adding complexity, where a more experienced dev would simplify. I lack that experience, and need help.
It's a menu editor, where I load a menu object from my database into state:
state = {
user_token: ####,
loadingMenu: true,
menu: {} // menu will be fetched into here
}
The object looks like this:
{
menuID: _605c7e1f54bb42972e420619,
brandingImg: "",
specials: "2 for 1 drinks",
langs: ["en", "es"],
items: [
{
id: 0,
type: "menuitem",
isVisible: true,
en: {
name: "sandwich 1",
desc: "Chicken sandwish"
},
es: {
name: "torta 1"
},
price: 10
},
// ...
// ABOUT 25 MORE ITEMS
]
}
The UI allows user to click on and update the items individually. So when they change the text I find myself having to do weird destructuring, like this:
function reducer(state, action) {
if (action.type === UPDATE_NAME) {
const newMenuItems = state.menu.items.map((oldItem) => {
if (oldItem.id === action.payload.id) {
return { ...oldItem, ["en"]: { ...oldItem["en"], name: action.payload.newName } }
// ["en"] for now, but will be dynamic later
}
return oldItem
})
return { ...state, menu: { ...state.menu, items: newMenuItems } }
}
}
This seems like a a bad idea, because I'm replacing the entirety of state with my new object. I'm wondering if there is a better way to organize it?
I know there are immutability managers, and I tried to use immer.js, but ran into an obstacle. I need to map through all my menu items to find the one user wants to edit (matching the ID to the event target's ID). I don't know how else to target it directly, and don't know how to do this:
draft.menu.items[????][lang].name = "Sandwich One"
So again, I'm thinking that my organisation is wrong, as immutability managers should probably make this easy. Any ideas, what I can refactor?

First of all, your current reducer looks fine. That "weird destructuring" is very typical. You will always replace the entirety of state with a new object, but you are dealing with shallow copies so it's not an entirely new object at every level. The menu items which you haven't modified are still references to the same objects.
I need to map through all my menu items to find the one user wants to edit (matching the ID to the event target's ID). I don't know how else to target it directly.
You would use .findIndex() to get the index of the item that you want to update.
const {lang, name, id} = action.payload;
const index = draft.menu.items.findIndex( item => item.id === id);
if ( index ) { // because there could be no match
draft.menu.items[index][lang].name = name;
}
This is more of an organisation than technical question. I think I may be adding complexity, where a more experienced dev would simplify. I lack that experience, and need help.
My recommendation for the state structure is to store all of the items in a dictionary keyed by id. This makes it easier to update an item because you no longer need to find it in an array.
const {lang, name, id} = action.payload;
draft.items[index][lang].name = name;
The menu object would just have an array of ids instead of an array of objects for the items property. When you select a menu, your selector can replace the ids with their objects.
const selectMenu = (state) => {
const menu = state.menu;
return { ...menu, items: menu.items.map((id) => state.items[id]) };
};

Related

Is this a React prevState issue and how can I solve it?

This is kind of complicated to explain. I'm trying to make a toggle function that adds and deletes items on a different page with useContext. Everything adds and deletes fine until I tab to the other page, and that's where the error begins. Once I do that the function ignores what's in the array and will duplicate items in the array. What's odd about it is if I console.log or manually check the new item with the current array items it shows me everything. For example in order to add the new item to the array it checks if the index of new item is -1. If it is it will add the item and if not it will delete the item. However once I leave the page it doesn't see the item anymore and adds it anyway. If I console.log the item name and new item name, I can see both, and if I use === to check it also works fine until I switch tabs and then even though it's still console logging both names somehow it's still adding the item and ignoring that it already contains the item.
The code directory in my sandbox is src/Helpers/MyPicsContext. Here is the link to my sandbox codesandbox
The tabs on the website are Picks and the search page which you can access on Picks before any items are added or by clicking the magnifying glass in the top right of page.
And here is the actual code for the context page.
export const MyPicksContextProvider = props => {
const [picksList, setPicksList] = useState(
JSON.parse(localStorage.getItem('picksList'))
||
[]
)
//console.log(picksList)
useEffect(() => {
localStorage.setItem("picksList", JSON.stringify(picksList));
}, [picksList]);
const deleteCoin = coin => {
if (picksList.length === 1) {
setPicksList([]);
} else {
setPicksList(picksList.filter(list => {
console.log(list)
//console.log(coin)
return list !== coin;
}))
}
console.log('deleted');
console.log(picksList)
}
const toggleCoin = (coin) => {
if (picksList.length === 0) {
setPicksList([...picksList, coin]);
} else {
if (picksList.indexOf(coin) === -1 ) {
setPicksList([...picksList, coin]);
console.log('added 1')
} else {
deleteCoin(coin)
}
}
}
Perhaps I just don't understand useState and prevState, but I can't seem to find any examples that apply to what I'm trying to do here. It makes total sense in creating a counter or something simple like that.
The issue is that .indexOf checks for referential equality of items, same as using ===. This means that const a = {id: 'x'}; const b = a; console.log(a === b); will output true, but const a = {id: 'x'}; const b = {id: 'x'}; console.log(a === b); will output false.
In your situation, upon changing pages / refreshing, the state is reset and loaded from local storage. However, this creates new objects which are not referentially equal. Instead of using .indexOf you want to use .find, something like array.find((element) => element.id === newItem.id) to find the index of the item. You could also do your own deep equality check (confirming every field matches), but I suspect the ID alone is sufficient.
In fact, I would also recommend only keeping the array of "picks" as an array of string ID's. Then you can lookup the full data from your table for each of these ID's. Otherwise the current price will be stored in localStorage, and could be out of date.

accept and canDrop (useDrop properties) not working properly to prevent taking duplicate item types

I'm trying to create a react component that lets the user drag randomly generated words of a handful of categories to build a short phrase. The draggable word elements each have one of these categories as their type. I want the drop target that they're being dragged to only accept one of each word category, and I've run into really strange behavior from the library for this seemingly simple operation.
I initially tried instantiating an array, freeCategories, that includes all the word categories, then iterates through the currentPhrase state variable (also an array) and removes each category that is currently in use.
I thought I could just pass freeCategories in the drop target component's props and then use that as the value for 'accept' in useDrop. That just let anything through no matter what was in the freeCategories array.
My second solution was to perform this logic on the drag source object. If the word's category was still available it would be taken out of freeCategories and its type would be "word-usable" or "word-blocked" if it wasn't. With the drop target's accept parameter set to "word-usable" it would still take anything even if its type was returning as "word-blocked".
Finally, I tried adding a canDrop property to useDrop. This checked the freeCategories array (passed in props) against the item's type (now back to the word's category). This caused the drop target to block everything with canDrop returning false even if the word type was in the array of free categories. When I tried switching the monitor from !!monitor.canDrop to !monitor.canDrop, it now looks like it will accept any word, but when dropped it doesn't run the drop function.
The function that builds the jsx of the phrase:
function currentPhraseDisp() {
let freeCategories = ["Form", "Element", "Verb"];
currentPhrase.forEach(word => freeCategories.splice(freeCategories.indexOf(word.category), 1));
let startSpace = currentSpell.length < 3 ? <WordSpace start={true} accepts={freeCategories} addWordToCurrentPhrase={addWordToCurrentPhrase} /> : <></>;
return (
<>
{startSpace}
{currentPhrase.map((w, i) => {
return (
<div key={i} className="current-phrase-word">
<div>{w['word']}</div>
<button>-</button>
</div>
)
})}
</>
)
}
Words come through as objects in the format {word: 'word', category: 'word category'}
The dragging logic:
const [{ isDragging }, drag] = useDrag({
/*Used in the second solution*/ item: { type: 'word-usable', id: props.word },
item: { type: props.word.category, id: props.word},
collect: monitor => ({
isDragging: !!monitor.isDragging()
})
})
The dropping logic:
const [{ isOver, canDrop }, drop] = useDrop({
accept: props.accepts,
/*Used in solution 3
canDrop: (item) => {
props.accepts.includes(item.type);
},
*/
drop: (item) => {
props.addWordToCurrentPhrase(item.id, props.start);
},
collect: monitor => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop()
})
})
I would like these components to allow the user to drag only one of any given word type into currentPhrase. So far my results have been that the drop target either accepts all words or no words. If anyone can help me understand what react-dnd is doing here, I'd appreciate it because it seems like it won't allow any changes to accept or item types on rerender.

How to update nested object in redux

I have an object:
{ //birthdaysObject
'2000':{
'January':
[{
name: 'Jerry'
},
{
name: 'Chris'
}]
},
'2001':{
'February':
[{
name: 'John'
}]
}
When I go to update the redux store it is replacing the entire year (eg. '2000') object with the new one that I send to my reducer.
How can I push the the nested array of objects without replacing the entire year object?
My reducer currently looks like:
return Object.assign({}, state, {
...state,
birthdays: Object.assign({}, state.birthdays, {
...state.birthdays,
...birthdays
})
});
Where ...birthdays would be another object in the same format as the first code snippet.
I am also open to suggestions about the structure of my data, normalizing, etc.
Any help would be appreciated.
The object keys in the birthdaysObject are unknown and are assigned when iterating through a separate object. I've tried kolodny/immutability-helper however the $merge function is returning the same results as what my reducer is already doing.
I had the same problem some time ago.
Follow the way I done it.
You have an object, but I think you should have an array of objects.
I also have different names on variables, but this should not be a problem to understand the logic
//do a copy of the array first
let newSubscriptions = state.customer.master.subscriptions.slice();
//for the value you want to change, find it's position in the array first
const indexInSubscriptions = newSubscriptions.map(function(item) {
return item.id;
}).indexOf( action.id);
//get the child you want to edit and keep it in a new variable
const under_edit_subscription = state.customer.master.subscriptions[indexInSubscriptions];
//go again over the array and where is the value at the index find above, replace the value
newSubscriptions = newSubscriptions.map((item, i) =>
i === indexInSubscriptions ? under_edit_subscription : item
)
//add the whole array into the state
return {
...state,
customer: {
...state.customer,
master: {
...state.customer.master,
subscriptions : newSubscriptions
}
}
}

How to update an object in an array inside store? [duplicate]

I have a todo list and want to set the state of that item in the array to "complete" if the user clicks on "complete".
Here is my action:
export function completeTodo(id) {
return {
type: "COMPLETE_TASK",
completed: true,
id
}
}
Here is my reducer:
case "COMPLETE_TASK": {
return {...state,
todos: [{
completed: action.completed
}]
}
}
The problem I'm having is the new state does no longer have the text associated of that todo item on the selected item and the ID is no longer there. Is this because I am overwriting the state and ignoring the previous properties? My object item onload looks like this:
Objecttodos: Array[1]
0: Object
completed: false
id: 0
text: "Initial todo"
__proto__: Object
length: 1
__proto__: Array[0]
__proto__: Object
As you can see, all I want to do is set the completed value to true.
You need to transform your todos array to have the appropriate item updated. Array.map is the simplest way to do this:
case "COMPLETE_TASK":
return {
...state,
todos: state.todos.map(todo => todo.id === action.id ?
// transform the one with a matching id
{ ...todo, completed: action.completed } :
// otherwise return original todo
todo
)
};
There are libraries to help you with this kind of deep state update. You can find a list of such libraries here: https://github.com/markerikson/redux-ecosystem-links/blob/master/immutable-data.md#immutable-update-utilities
Personally, I use ImmutableJS (https://facebook.github.io/immutable-js/) which solves the issue with its updateIn and setIn methods (which are more efficient than normal objects and arrays for large objects with lots of keys and for arrays, but slower for small ones).
New state does no longer have the text associated of that todo item on
the selected item and the ID is no longer there, Is this because I am
overwriting the state and ignoring the previous properties?
Yes, because during each update you are assigning a new array with only one key completed, and that array doesn't contain any previous values. So after update array will have no previous data. That's why text and id's are not there after update.
Solutions:
1- Use array.map to find the correct element then update the value, Like this:
case "COMPLETE_TASK":
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, completed: action.completed } : todo
)
};
2- Use array.findIndex to find the index of that particular object then update that, Like this:
case "COMPLETE_TASK":
let index = state.todos.findIndex(todo => todo.id === action.id);
let todos = [...state.todos];
todos[index] = {...todos[index], completed: action.completed};
return {...state, todos}
Check this snippet you will get a better idea about the mistake you are doing:
let state = {
a: 1,
arr: [
{text:1, id:1, completed: true},
{text:2, id:2, completed: false}
]
}
console.log('old values', JSON.stringify(state));
// updating the values
let newState = {
...state,
arr: [{completed: true}]
}
console.log('new state = ', newState);
One of the seminal design principles in React is "Don't mutate state." If you want to change data in an array, you want to create a new array with the changed value(s).
For example, I have an array of results in state. Initially I'm just setting values to 0 for each index in my constructor.
this.state = {
index:0,
question: this.props.test[0].questions[0],
results:[[0,0],[1,0],[2,0],[3,0],[4,0],[5,0]],
complete: false
};
Later on, I want to update a value in the array. But I'm not changing it in the state object. With ES6, we can use the spread operator. The array slice method returns a new array, it will not change the existing array.
updateArray = (list, index,score) => {
// updates the results array without mutating it
return [
...list.slice(0, index),
list[index][1] = score,
...list.slice(index + 1)
];
};
When I want to update an item in the array, I call updateArray and set the state in one go:
this.setState({
index:newIndex,
question:this.props.test[0].questions[newIndex],
results:this.updateArray(this.state.results, index, score)
});

React-Redux update state of immutable object with nested array, delete element from array

I have the following object, from which I want to remove one comment.
msgComments = {
comments: [
{ comment:"2",
id:"0b363677-a291-4e5c-8269-b7d760394939",
postId:"e93863eb-aa62-452d-bf38-5514d72aff39" },
{ comment:"1",
id:"e88f009e-713d-4748-b8e8-69d79698f072",
postId:"e93863eb-aa62-452d-bf38-5514d72aff39" }
],
email:"test#email.com",
id:"e93863eb-aa62-452d-bf38-5514d72aff39",
post:"test",
title:"test"
}
The action creator hits the api delete function with the commentId:
// DELETE COMMENT FROM POST
export function deleteComment(commentId) {
return function(dispatch) {
axios.post(`${API_URL}/datacommentdelete`, {
commentId
},{
headers: { authorization: localStorage.getItem('token') }
})
.then(result => {
dispatch({
type: DELETE_COMMENT,
payload: commentId
});
})
}
}
My api deletes the comment and I send the comment id to my Reducer, this is working fine to this point, api works and comment is deleted. The problem is updating the state in the reducer. After much trial and error at the moment I am trying this.
case DELETE_COMMENT:
console.log('State In', state.msgComments);
const msgCommentsOne = state.msgComments;
const msgCommentsTwo = state.msgComments;
const deleteIndexComment = state.msgComments.data.comments
.findIndex(elem => elem.id === action.payload );
const newComments = [
...msgCommentsTwo.data.comments.slice(0, deleteIndexComment),
...msgCommentsTwo.data.comments.slice(deleteIndexComment + 1)
];
msgCommentsOne.data.comments = newComments;
console.log('State Out', msgCommentsOne);
return {...state, msgComments: msgCommentsOne};
Both state in AND state out return the same object, which has the appropriate comment deleted which I find puzzling.
Also the component is not updating (when I refresh the comment is gone as a new api call is made to return the updated post.
Everything else seems to work fine, the problem seems to be in the reducer.
I have read the other posts on immutability that were relevant and I am still unable to work out a solution. I have also researched and found the immutability.js library but before I learn how to use that I wanted to find a solution (perhaps the hard way, but I want to understand how this works!).
First working solution
case DELETE_COMMENT:
const deleteIndexComment = state.msgComments.data.comments
.findIndex(elem => elem.id === action.payload);
return {
...state, msgComments: {
data: {
email: state.msgComments.data.email,
post: state.msgComments.data.post,
title: state.msgComments.data.title,
id: state.msgComments.data.id,
comments: [
...state.msgComments.data.comments.slice(0, deleteIndexComment),
...state.msgComments.data.comments.slice(deleteIndexComment + 1)
]
}
}
};
Edit:
Second working solution
I have found a second far more terse solution, comments welcome:
case DELETE_COMMENT:
const deleteIndexComment = state.msgComments.data.comments
.findIndex(elem => elem.id === action.payload);
return {
...state, msgComments: {
data: {
...state.msgComments.data,
comments: [
...state.msgComments.data.comments.slice(0, deleteIndexComment),
...state.msgComments.data.comments.slice(deleteIndexComment + 1)
]
}
}
};
That code appears to be directly mutating the state object. You've created a new array that has the deleted item filtered out, but you're then directly assigning the new array to msgCommentsOne.data.comments. The data field is the same one that was already in the state, so you've directly modified it. To correctly update data immutably, you need to create a new comments array, a new data object containing the comments, a new msgComments object containing the data, and a new state object containing msgComments. All the way up the chain :)
The Redux FAQ does give a bit more information on this topic, at http://redux.js.org/docs/FAQ.html#react-not-rerendering.
I have a number of links to articles talking about managing plain Javascript data immutably, over at https://github.com/markerikson/react-redux-links/blob/master/immutable-data.md . Also, there's a variety of utility libraries that can help abstract the process of doing these nested updates immutably, which I have listed at https://github.com/markerikson/redux-ecosystem-links/blob/master/immutable-data.md.

Resources