Is this the correct way to update state? - reactjs

My Component looks like this:
import cloneDeep from "clone-deep";
import { Context } from "../../context";
const Component = () => {
const context = useContext(Context);
const [state, setState] = useState(
{
_id: "123",
users: [
{
_id: "1",
points: 5
},
{
_id: "2",
points: 8
}
]
}
);
useEffect(() => {
context.socket.emit("points");
context.socket.on("points", (socketData) => {
setState(prevState => {
const newState = {...prevState};
const index = newState.users
.findIndex(user => user._id == socketData.content._id);
newState.users[index].points = socketData.content.points;
return newState;
})
});
return () => context.socket.off("points");
}, []);
return <div>(There is table with identificators and points)</div>
};
I wonder if this is the right approach. I just want to write the code in the right way.
Or maybe it's better with the use of deep cloning? Does it matter?
setState(prevState => {
const newState = cloneDeep(prevState);
const index = newState.users
.findIndex(user => user._id == "2");
newState.users[index].points++;
return newState;
})
EDIT: I added the rest of the code to make it easier to understand.

In your current code:
useEffect(() => {
setState((prevState) => {
const newState = { ...prevState };
const index = newState.users.findIndex((user) => user._id == "2");
newState.users[index].points++;
console.log({ prevState, newState });
return newState;
});
}, []);
You can see that prevState is being mutated (points is 9):
{
_id: "123",
users: [
{
_id: "1",
points: 5
},
{
_id: "2",
points: 9 // Mutated!
}
]
}
To avoid mutating the state, you have to use not mutating methods such as spread operator or map function:
useEffect(() => {
setState((prevState) => {
const newState = ({
...prevState,
users: prevState.users.map((user) =>
user._id === "2"
? {
...user,
points: user.points + 1
}
: user
)
})
console.log({ prevState, newState });
return newState
}
);
}, []);
Now you can see that the prevState is not mutated:
{
_id: "123",
users: [
{
_id: "1",
points: 5
},
{
_id: "2",
points: 8 // Not mutated :)
}
]
}

Your code will work, but the problem will start when your state becomes bigger.
You currently have only two properties on the state, the _id and the users. If in the future will add more and more properties like loggedUser and settings and favorites, and more... your application will render everything on every state change.
At this point you will have to start thinking about other solutions to state management, like redux, mobx, or just split the state to smaller useState, also you can look into useReducer in complex structures.

Related

Patch multiple id's with one request with React Query

I have a very basic prototype of app that allows to book a seat. User selects the seat/seats, clicks book, patch request with available: false is sent to the fake api (json-server) with React Query, library invalidates the request and immediately shows response from the server.
Database structure looks like this:
{
"hallA": [
{
"id": 1,
"seat": 1,
"available": false
},
{
"id": 2,
"seat": 2,
"available": true
},
{
"id": 3,
"seat": 3,
"available": false
}
]
}
and the logic for selecting, booking seats looks like this:
const App = () => {
const { data, isLoading } = useGetHallLayout("hallA");
const [selected, setSelected] = useState<
{ id: number; seat: number; available: boolean }[]
>([]);
const handleSelect = useCallback(
(seat: { id: number; seat: number; available: boolean }) => {
const itemIdx = selected.findIndex((element) => element.id === seat.id);
if (itemIdx === -1) {
setSelected((prevState) => [
...prevState,
{ id: seat.id, seat: seat.seat, available: !seat.available },
]);
} else {
setSelected((prevState) =>
prevState.filter((element) => element.id !== seat.id)
);
}
},
[selected]
);
const takeSeat = useTakeSeat({
onSuccess: () => {
useGetHallLayout.invalidate();
},
});
const sendRequest = useCallback(() => {
selected.forEach((item) =>
takeSeat.mutateAsync({ id: item.id, hall: "hallA" })
);
setSelected([]);
}, [selected, takeSeat]);
return (
<>
{!isLoading && (
<ConcertHall
layout={data}
onSeatSelect={handleSelect}
activeSeats={selected}
/>
)}
<button disabled={isLoading} onClick={sendRequest}>
Take selected
</button>
</>
);
};
Queries look like this:
export const useGetHallLayout = (hall: string) => {
const { data, isLoading } = useQuery(["hall"], () =>
axios.get(`http://localhost:3000/${hall}`).then((res) => res.data)
);
return { data, isLoading };
};
export const useTakeSeat = (options?: UseMutationOptions<unknown, any, any>) =>
useMutation(
(data: { hall: string; id: number }) =>
axios.patch(`http://localhost:3000/${data.hall}/${data.id}`, {
available: false,
}),
{
...options,
}
);
useGetHallLayout.invalidate = () => {
return queryClient.invalidateQueries("hall");
};
The problem of the above code is that I perform very expensive operation of updating each id in a for each loop (to available: false) and query invalidates it after each change not once all of them are updated.
The question is: is there any better way to do this taking into account the limitations of json-server? Any batch update instead of sending request to each and every id seperately? Maybe some changes in a logic?
Thanks in advance
You can certainly make one mutation that fires of multiple requests, and returns the result with Promise.all or Promise.allSettled. Something like:
useMutation((seats) => {
return Promise.allSettled(seats.map((seat) => axios.patch(...))
})
then, you would have one "lifecycle" (loading / error / success) for all queries together, and onSuccess will only be called once.
Another gotcha I'm seeing is that you'd really want the hall string to be part of the query key:
- useQuery(["hall"], () =>
+ useQuery(["hall", hall], () =>

React State Updation Issue

My component's state is as below:
const [state, setState] = useState({
teamMembersOptions: [],
selectedTeamMember: {},
});
teamMembersOptions are being mapped from the redux state teamMembersList as below:
const teamMembersList = useSelector(state => state.get_all_team_members.team)
useEffect(() => {
if (teamMembersList)
mapTeamMembers();
}, [teamMembersList])
const mapTeamMembers = () => {
const teamMembers = [];
teamMembersList.map(member => {
const memberObject = {
'value': member.id,
'label': member.first_name.charAt(0).toUpperCase() + member.first_name.slice(1) + ' ' + member.last_name.charAt(0).toUpperCase() + member.last_name.slice(1)
}
if (member.is_leader == 1) {
memberObject.label = memberObject.label + ' (owner)'
setState({
...state,
selectedTeamMember: memberObject
})
}
teamMembers.push(memberObject)
})
setState({
...state,
teamMembersOptions: teamMembers
})
}
The state variables of selectedTeamMember and teamMemberOptions are not updating, it keeps consoling empty state. Whenever I console the local array of teamMembers inside mapTeamMembers function, it logs all the values successfully teamMembersList from Redux
also logs successfully that means teamMembersList and teamMembers are not empty. But the state is not updating. Why the setState statement inside mapTeamMembers function is not updating the state?
There are a number of things going on here and lot of them cause renders to trigger more renders which is why you are getting unexpected output.
I have add useMemo() and useCallback() around the data and calculation method respectively, and added their return values to the dependency array for useEffect(). This is to avoid the useEffect dependencies change on every render.
Calling setState() within the .map() function doesn't feel like the right choice either as each time it is called a render might occur, even though you are halfway through the mapping operation. Instead I suggest, and opted for, using .reduce() on the array and returning that result which can then be used to update the state within the useEffect hook.
Have a look at the working code below and a sample output given the defined input from teamMembersList. Note: this doesn't use Redux in the example given that it more setup to prove the concept.
import { useCallback, useEffect, useMemo, useState } from "react";
export default function App() {
const [state, setState] = useState({
teamMembersOptions: [],
selectedTeamMember: {}
});
const teamMembersList = useMemo(
() => [
{ id: 1, first_name: "John", last_name: "Smith", is_leader: 0 },
{ id: 2, first_name: "Maggie", last_name: "Simpson", is_leader: 1 }
],
[]
);
const mapTeamMembers = useCallback(
() =>
teamMembersList.reduce(
(acc, member) => {
const memberObject = {
value: member.id,
label:
member.first_name.charAt(0).toUpperCase() +
member.first_name.slice(1) +
" " +
member.last_name.charAt(0).toUpperCase() +
member.last_name.slice(1)
};
if (member.is_leader === 1) {
memberObject.label = memberObject.label + " (owner)";
acc.leader = memberObject;
}
acc.teamMembers.push(memberObject);
return acc;
},
{
teamMembers: [],
leader: ""
}
),
[teamMembersList]
);
useEffect(() => {
if (teamMembersList) {
const members = mapTeamMembers();
setState({
selectedTeamMember: members.leader,
teamMembersOptions: members.teamMembers
});
}
}, [teamMembersList, mapTeamMembers, setState]);
return (
<div>
<pre>
<code>{JSON.stringify(state, null, 4)}</code>
</pre>
</div>
);
}
The above will render out:
{
"selectedTeamMember": {
"value": 2,
"label": "Maggie Simpson (owner)"
},
"teamMembersOptions": [
{
"value": 1,
"label": "John Smith"
},
{
"value": 2,
"label": "Maggie Simpson (owner)"
}
]
}
I'd consider splitting the state object into individual state items but that's really up to you and how you want to handle the data.

useSelector doesnot update the UI

I have a nested state like :
bookingDetails = {
jobCards: [
{
details_id: '1',
parts: [
{...},
{...}
]
}
]}
Now I got the respective jobCards in component from props from parent component i.e detailsID by using useSelector:
const jobCard = useSelector(state => state.bookingDetails.jobCards.find(item => item.details_id === detailsID))
I got a button that successfully adds new object in parts in respective jobCards but that doesnot update the UI.
My bookingDetails Reducer:
case 'ADD_PARTS':
return {
...state,
jobCards: state.jobCards.map(jobCard => {
if (jobCard.details_id === action.id) {
jobCard.parts = [...jobCard.parts, { _id: uuid(), name: '' }]
}
return jobCard
})
}
use like this
const [isJobUpdated, setIsJobUpdated] = useState(false);
const jobCard = useSelector(state => state.bookingDetails.jobCards.find(item => item.details_id === detailsID))
useEffect(() => {
setIsJobUpdated(!!jobCard.length);
}, [jobCard])
return (
<>
{isJobUpdated && <YourComponent />
</>
)
NOTE: this is not the best way to do. You might face re-render issue. Just to check if this solve your current issue.
Forgot to add return statement.
The reducer should have been:
case 'ADD_PARTS':
return {
...state,
jobCards: state.jobCards.map(jobCard => {
if (jobCard.details_id === action.id) {
return {
...jobCard,
parts: [...jobCard.parts, { id: uuid(), name: ''}]
}
}
return jobCard
})
}

Toggle state of object key value in an array

I need to create a reducer that toggles the state of done using the id or index of the todo
state = {
todos: [
{
title: "eat rice",
done: false,
id: 1
},
{
title: "go fishing",
done: true,
id: 2
},
{
title: "drink coffee",
done: false,
id: 3
}
]
}
I tried this but it mutates the state, the payload being the index of the object in the array.
case "DONE":
const todos = [...state.todos];
todos[action.payload].done = !todos[action.payload].done;
return {
...state,
todos: todos
};
You could use a map function instead. The function will generate a new array which you can use to replaces todos with.
case "DONE":
const newTodos = state.todos.map((todo, index) => {
// Based on your code, I assume action.payload is the index of the todo in the array of todos
if (index === action.payload) {
const newTodo = {...todo};
todo.done = !todo.done;
return todo;
}
return todo;
});
return {
...state,
todos: newTodos,
};
If you don't want to iterate over every todo, you could do something else such as using slice to create a copy of the array and then change the one value:
case "DONE":
const newTodos = todos.slice();
const updatedTodo = {...newTodos[action.payload]};
updatedTodo.done = !updatedTodo.done;
newTodos[action.payload] = updatedTodo;
return {
...state,
todos: newTodos,
};
Found the answer. Thanks for the contributions.
case "DONE":
const newTodos = state.todos.map((todo, index) => {
if (index === action.payload) {
const newTodo = { ...todo };
newTodo.done = !newTodo.done;
return newTodo;
}
return todo;
});
return {
...state,
todos: newTodos
};
Using the spread operator or map will create a new array but will not automatically clone the contained objects, as JavaScript follows "pass by reference". You'd have to clone the object as well. So maybe something like
case "DONE":
const todos = state.todos.map((todo, index) => {
const newTodo = {...todo};
if (action.payload === index) {
newTodo.done = !todo.done;
}
return newTodo;
});
return {
...state,
todos,
};
Of course you could also use a clone utility or something like Immutable.js.

How to safely update my state when I have to traverse and lookup/remove items in my state

I need to modify my state and I am unsure how to do it correctly.
My account property in my state looks something like this:
{
"account":{
"id":7,
"categories":[
{
"id":7,
"products":[
{
"productId":54
}
]
},
{
"id":9,
"products":[
{
"productId":89
}
]
}
]
}
}
My action dispatches the following:
dispatch({
type: Constants.MOVE_PRODUCT,
productId: 54,
sourceCategoryId: 7,
targetCategoryId: 9
});
Now my reducer skeleton is:
const initialState = {
account: null,
};
const accounts = (state = initialState, action) => {
switch (action.type) {
case Constants.MOVE_PRODUCT:
/*
action.productId
action.sourceCategoryId
action.targetCategoryId
*/
const sourceCategoryIndex = state.account.categories.findIndex((category) => { return category.id === action.sourceCategoryId; });
const sourceCategory = state.account.categories[sourceCategoryIndex];
const targetCategoryIndex = state.account.categories.findIndex((category) => { return category.id === action.targetCategoryId; });
const targetCategory = state.account.categories[targetCategoryIndex];
// ??
return {...state};
}
}
export default accounts;
I am confused, if I update the state directly inside of the switch block, is that wrong?
Does it have to be a one-liner update that does the mutation in-place or as long as I do it in the switch block it is fine?
Update
From the action, I need to remove the productId from the sourceCategoryId and add it to the targetCategoryId inside of the account state object.
Yes, you should not be doing state.foo = 'bar' in your reducer. From the redux docs:
We don't mutate the state. We create a copy with Object.assign(). Object.assign(state, { visibilityFilter: action.filter }) is also wrong: it will mutate the first argument. You must supply an empty object as the first parameter. You can also enable the object spread operator proposal to write { ...state, ...newState } instead.
So your reducer could look like
function accountsReducer (state = initialState, { sourceCategoryId, productId }) {
const targetProduct = state.categories
.find(({ id }) => id === sourceCategoryId)
.products
.find(({ id }) => id === productId);
switch (action.type) {
case Constants.MOVE_PRODUCT:
return {
...state,
categories: state.categories.reduce((acc, cat) => {
return cat.id !== sourceCategoryId
? {
...acc,
cat: { ...cat, products: cat.products.filter(({ id }) => id !== productId) }
}
: {
...acc,
cat: { ...cat, products: [...cat.products, targetProduct] }
}
}, {});
};
}
}
But this a pain...you should try to normalize your data into a flat array.
// first, let's clean up the action a bit
// type and "payload". I like the data wrapped up in a bundle with a nice
// bow on it. ;) If you don't like this, just adjust the code below.
dispatch({
type: Constants.MOVE_PRODUCT,
payload: {
product: { productId: 54 }
sourceCategoryId: 7,
targetCategoryId: 9
}
});
// destructure to get our id and categories from state
const { id, categories } = state
// map the old categories to a new array
const adjustedCategories = categories.map(cat => {
// destructure from our payload
const { product, sourceCategoryId, targetCategoryId } = action.payload
// if the category is the "moving from" category, filter out the product
if (cat.id === sourceCategoryId) {
return { id: cat.id, products: [...cat.products.filter(p => p.productId !== product.productId)
}
// if the category is our "moving to" category, use the spread operator and add the product to the new array
if (cat.id === targetCategoryId) {
return { id: cat.id, products: [...cat.products, product] }
}
)
// construct our new state
return { id, categories: adjustedCategories }
This solution keeps the function pure and should give you what you want. It's not tested, so may not be perfect.
You could take the following approach:
const accounts = (state = initialState, action) => {
switch (action.type) {
case Constants.MOVE_PRODUCT:
// Extract action parameters
const { productId, sourceCategoryId, targetCategoryId } = action
// Manually "deep clone" account state
const account = {
id : state.account.id,
categories : state.account.categories.map(category => ({
id : category.id,
products : category.products.map(product => ({ productId : product.productId })
}))
}
// Extract source and target categories
const sourceCategory = account.categories.find(category => category.id === sourceCategoryId);
const targetCategory = account.categories.find(category => category.id === targetCategoryId);
if(sourceCategory && targetCategory) {
// Find product index
const index = sourceCategory.products.findIndex(product => (product.productId === action.productId))
if(index !== -1) {
const product = sourceCategory.products[index]
// Remove product from source category
sourceCategory.products.splice(index, 1)
// Add product to target category
targetCategory.products.splice(index, 0, product)
}
}
return { account };
}
}
Here is the ugly solution :)
const accounts = (state = initialState, action) => {
switch (action.type) {
case Constants.MOVE_PRODUCT:
const sourceCategoryIndex = state.account.categories.findIndex(
el => el.id === action.sourceCategoryId
);
const targetCategoryIndex = state.account.categories.findIndex(
el => el.id === action.targetCategoryId
);
const sourceCategory = state.account.categories.find(
el => el.id === action.sourceCategoryId
);
const targetCategory = state.account.categories.find(
el => el.id === action.targetCategoryId
);
const itemToMove = sourceCategory.products.find(
el => el.productId === action.productId
);
const newSourceCategory = {
...sourceCategory,
products: sourceCategory.products.filter(
el => el.productId !== action.productId
)
};
const newTargetCategory = {
...targetCategory,
products: [...targetCategory.products, itemToMove]
};
const newCategories = Object.assign([], state.account.categories, {
[sourceCategoryIndex]: newSourceCategory,
[targetCategoryIndex]: newTargetCategory
});
return { ...state, account: { ...state.account, categories: newCategories } };
}
};
Phew :) As a learner it's quite good for me :) But, I like #Daniel Lizik's approach, using reduce.
Here is the working example:
const action = {
productId: 54,
sourceCategoryId: 7,
targetCategoryId: 9,
}
const state = {
"account":{
"id":7,
"categories":[
{
"id":7,
"products":[
{
"productId":54,
},
{
"productId":67,
},
]
},
{
"id":9,
"products":[
{
"productId":89,
}
]
}
]
}
};
const sourceCategoryIndex = state.account.categories.findIndex( el => el.id === action.sourceCategoryId );
const targetCategoryIndex = state.account.categories.findIndex( el => el.id === action.targetCategoryId );
const sourceCategory = state.account.categories.find( el => el.id === action.sourceCategoryId );
const targetCategory = state.account.categories.find( el => el.id === action.targetCategoryId );
const itemToMove = sourceCategory.products.find( el => el.productId === action.productId );
const newSourceCategory = {...sourceCategory, products: sourceCategory.products.filter( el => el.productId !== action.productId ) };
const newTargetCategory = { ...targetCategory, products: [ ...targetCategory.products, itemToMove ] };
const newCategories = Object.assign([], state.account.categories, { [sourceCategoryIndex]: newSourceCategory,
[targetCategoryIndex]: newTargetCategory }
);
const newState = { ...state, account: { ...state.account, categories: newCategories } };
console.log( newState );

Resources