.map() method using prior state in React - javascript

My app allows users to click on player cards in a Field section, and then for the selected player cards to appear in a Teams section. I have an array (called selectedPlayers) and initially, each element has a default player name and default player image. As the users select players, the elements in the array are replaced one-by-one by the name and image of the selected players.
The state of the array is set in a parent component and then the array is passed to a TeamsWrapper component as a prop. I then map through the array, returning a TeamsCard component for each element of the array. However, my TeamsCards are always one selection behind reality. In other words, after the first player is selected, the first card still shows the default info; after the second player is selected, the first card now reflects the first selection, but the second card still shows the default info. The code for the TeamsWrapper component is below:
import React from "react";
import "./style.css";
import TeamsCard from "../TeamsCard";
function TeamsWrapper(props) {
const { selectedPlayers } = props;
console.log('first console.log',selectedPlayers)
return (
<div className="teamsWrapper">
{selectedPlayers.map((el, i) => {
console.log('second console.log',selectedPlayers)
console.log('third console.log',el)
return (
<div key={i}>
<TeamsCard
image={el.image}
playerName={el.playerName}
/>
</div>
);
})}
</div>
);
}
export default TeamsWrapper;
I did have this working fine before when the parent was a class-based component. However, I changed it to a function component using hooks for other purposes. So, I thought the issue was related to setting state, but the console logs indicate something else (I think). After the first player is selected:
the first console log shows a correctly updated array (i.e. the first element reflects the data for the selected player, not the placeholder data)
the second console log reflects the same
but the third print still shows the placeholder data for the first element
As mentioned above, as I continue to select players, this third print (and the TeamsCards) is always one selection behind.
EDIT:
Here is the code for the parent component (Picks), but I edited out the content that was not relevant to make it easier to follow.
import React, { useState } from "react";
import { useStateWithCallbackLazy } from "use-state-with-callback";
import TeamsWrapper from "../TeamsWrapper";
import FieldWrapper from "../FieldWrapper";
const Picks = () => {
const initialSelectedPlayers = [
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
{ playerName: "default name", image: "https://defaultimage" },
];
const [count, setCount] = useStateWithCallbackLazy(0);
const [selectedPlayers, setSelectedPlayers] = useState(
initialSelectedPlayers
);
const handleFieldClick = (props) => {
// check to make sure player has not already been picked
const match = selectedPlayers.some(
(el) => el.playerName === props.playerName
);
if (match) {
return;
} else {
setCount(count + 1, (count) => {
updatePickPhase(props, count);
});
}
};
const updatePickPhase = (props, count) => {
if (count <= 15) {
updateTeams(props, count);
}
// elseif other stuff which doesn't apply to this issue
};
const updateTeams = (props, count) => {
const location = [0, 1, 2, 5, 4, 3];
const position = location[count - 1];
let item = { ...selectedPlayers[position] };
item.playerName = props.playerName;
item.image = props.image;
selectedPlayers[position] = item;
setSelectedPlayers(selectedPlayers);
};
return (
<>
<TeamsWrapper selectedPlayers={selectedPlayers}></TeamsWrapper>
<FieldWrapper
handleFieldClick={handleFieldClick}
count={count}
></FieldWrapper>
</>
);
};
export default Picks;
Thank you for your help!

When you update the array you are mutating state (updating existing variable instead of creating a new one), so React doesn't pick up the change and only re-render when count changes, try
const updateTeams = (props, count) => {
const position = count - 1;
const newPlayers = [...selectedPlayers];
newPlayers[position] = {playerName:props.playerName, image:props.image}
setSelectedPlayers(newPlayers);
};
This way React will see it is a new array and re-render.

Related

How to change global state from within a component using useContext? State change not registering

I would like to write an App that allows multiple components to add and remove entries to a set by accessing the global context.
So far I am initialising a Set with useState and feeding the state and the changeState values down to the child components using a context provider, I am then adding and removing items from the Set based on the props passed down into that component.
The code:
The main App file
/App.js
export const myListContext = createContext(new Set());
function App() {
const [myList, changeMyList] = useState(() => new Set());
const Alphabet = {
A: 'a',
B: 'b',
};
useEffect(() => {
console.log(myList);
}, [myList]);
return (
<myListContext.Provider value={{ myList, changeMyList }}>
<div className="my-box">
<Checkbox myEnum={Alphabet.A} title={"Add A to Set"}></Checkbox>
<Checkbox myEnum={Alphabet.B} title={"Add B to Set"}></Checkbox>
</div>
</myListContext.Provider>
);
}
/Checkbox.js
const Checkbox = ({ myEnum, title }) => {
const { myList, changeMyList } = useContext(myListContext);
const [index, setIndex] = useState(0);
function changeIndex() {
setIndex(index + 1);
if (index >= 3) {
setIndex(1);
}
}
function addListItem(item) {
changeMyList((prev) => new Set(prev).add(item));
}
function removeMyListItem(item) {
changeMyList((prev) => {
const next = new Set(prev);
next.delete(item);
return next;
});
}
function filterItem(item) {
changeIndex();
if (index === 0) {
addListItem(item);
}
if (index === 1) {
removeMyListItem(item);
}
}
return (
<div className="my-checkbox">
<div
className="my-checkbox-inner"
onClick={() => {
filterItem(myEnum);
}}
></div>
<div className="the-title">{title}</div>
</div>
);
}
The reason for this set up is to Add or Remove Items based on how many times the user has pressed the div, each component needs to track the status of its own index.
Unfortunately, the effect hook on the top layer of the app reports that myList does not contain the new entries.
Why does changeMyList not have a global impact despite change the state via a context.
What am I doing wrong?
console.log(myList) prints (0) depite clicking the buttons that aught to trigger a new entry based on myEnum.
Thanks!
Set is different than Array and it only contains distinct elements.
The Set object lets you store unique values of any type, whether primitive values or object references, according to MDN
Therefore, your code already worked. If you console log as myList.size will return 2 and [...myList] will return an array ["a", "b"] with an updated values and you can iterate over it to create necessary jsx and etc.
console.log([...myList]); // ["a", "b"]
console.log(myList.size); // 2

How to observe change in a global Set variable in useEffect inside react component function?

I have a page wherein there are Listings.
A user can check items from this list.
Whenever the user checks something it gets added to a globally declared Set(each item's unique ID is added into this set). The ID's in this set need to be accessed by a seperate Component(lets call it PROCESS_COMPONENT) which processes the particular Listings whose ID's are present in the set.
My Listings code roughly looks like:
import React from "react";
import { CheckBox, PROCESS_COMPONENT } from "./Process.jsx";
const ListItem = ({lItem}) => {
return (
<>
//name,image,info,etc.
<CheckBox lId={lItem.id}/>
</>
)
};
function Listings() {
// some declarations blah blah..
return (
<>
<PROCESS_COMPONENT /> // Its a sticky window that shows up on top of the Listings.
//..some divs and headings
dataArray.map(item => { return <ListItem lItem={item} /> }) // Generates the list also containing the checkboxes
</>
)
}
And the Checkbox and the PROCESS_COMPONENT functionality is present in a seperate file(Process.jsx).
It looks roughly like:
import React, { useEffect, useState } from "react";
let ProcessSet = new Set(); // The globally declared set.
const CheckBox = ({lID}) => {
const [isTicked, setTicked] = useState(false);
const onTick = () => setTicked(!isTicked);
useEffect( () => {
if(isTicked) {
ProcessSet.add(lID);
}
else {
ProcessSet.delete(lID);
}
console.log(ProcessSet); // Checking for changes in set.
}, [isTicked]);
return (
<div onClick={onTick}>
//some content
</div>
)
}
const PROCESS_COMPONENT = () => {
const [len, setLen] = useState(ProcessSet.size);
useEffect( () => {
setLen(ProcessSet.size);
}, [ProcessSet]); // This change is never being picked up.
return (
<div>
<h6> {len} items checked </h6>
</div>
)
}
export { CheckBox, PROCESS_COMPONENT };
The Set itself does get the correct ID values from the Checkbox. But the PROCESS_COMPONENT does not seem to be picking up the changes in the Set and len shows 0(initial size of the set).
I am pretty new to react. However any help is appreciated.
Edit:
Based on #jdkramhoft
's answer I made the set into a state variable in Listings function.
const ListItem = ({lItem,set,setPSet}) => {
//...
<CheckBox lID={lItem.id} pset={set} setPSet={setPSet} />
)
}
function Listings() {
const [processSet, setPSet] = useState(new Set());
//....
<PROCESS_COMPONENT set={processSet} />
dataArray.map(item => {
return <ListItem lItem={item} set={processSet} setPSet={setPSet} />
})
}
And corresponding changes in Process.jsx
const CheckBox = ({lID,pset,setPSet}) => {
//...
if (isTicked) {
setPSet(pset.add(lID));
}
else {
setPSet(pset.delete(lID));
}
//...
}
const PROCESS_COMPONENT = ({set}) => {
//...
setLen(set.size);
//...
}
Now whenever I click the check box I get an error:
TypeError: pset.add is not a function. (In 'pset.add(lID)', 'pset.add' is undefined)
Similar error occurs for the delete function as well.
First of all, the set should be a react state const [mySet, setMySet] = useState(new Set()); if you want react to properly re-render with detected changes. If you need the set to be available to multiple components you can pass it to them with props or use a context.
Secondly, React checks if dependencies like [ProcessSet] has been changed with something like ===. Even though the items in the set are different, no change is detected because the object is the same and there is no re-render.
Update:
The setState portion of [state, setState] = useState([]); is not intended to mutate the previous state - only to provide the next state. So to update your set you would do something like:
const [set, setSet] = useState(new Set())
const itemToAdd = ' ', itemToRemove = ' ';
setSet(prev => new Set([...prev, itemToAdd]));
setSet(prev => new Set([...prev].filter(item => item !== itemToRemove)));
As you might notice, this makes adding and removing from a set as slow as a list. So unless you need to make a lot of checks with set.has() I'd recommend using a list:
const [items, setItems] = useState([])
const itemToAdd = ' ', itemToRemove = ' ';
setItems(prev => [...prev, itemToAdd]);
setItems(prev => prev.filter(item => item !== itemToRemove));

sets and rendering in React

The ChooseElements Component renders list elements
depending of the state of each list item the item gets displayed differently
The toggleItem function sets for each element a new state if a clicked event occurs
why does using
const selectedItems = new Set(activeItems)
renders the changes immediately
but using const selectedItems = activeItems
the changes are getting displayed after a second re-render ?
export default function ChooseElements() {
const [activeItems, setActiveItems] = useState(new Set())
const toggleItem = (id) => {
const selectedItems = new Set(activeItems)
if (selectedItems.has(id)) {
selectedItems.delete(id)
} else {
selectedItems.add(id)
console.log(selectedItems.add(id))
}
setActiveItems(selectedItems)
}
const listItems = elementObjects.map((object) => (
<ListItem
key={object.id.toString()}
value={object.Element}
isActive={activeItems.has(object.id)}
clickItem={() => toggleItem(object.id)}
/>
))
return (
<div>
<button onClick={() => console.log(activeItems)}>gets sets</button>
<ul>{listItems}</ul>
<button
onClick={() => {
/* with button click get all list items with specific class! */
console.log(elementObjects.filter((o) => activeItems.has(o.id)))
}}
>
get Elements
</button>
</div>
)
}
child component
import React, { useState, useEffect } from 'react'
import './styles/choose-elements.css'
const elementObjects = [
{ id: 1, Element: 'element-1' },
{ id: 2, Element: 'element-2' },
{ id: 3, Element: 'element-3' },
{ id: 4, Element: 'element-4' },
]
function ListItem({ isActive, clickItem, value }) {
return (
<li className={isActive ? 'selected' : null} onClick={clickItem}>
{value}
</li>
)
}
It's pretty simple. Every time you do setActiveItems(), react does check the value of previous state and current state.
When you use const selectedItems = new Set(activeItems) it creates selectedItems in new address space. And updating with this state therefore triggers a re render. (and in the other case, the address space remains the same)
I'd illustrate further by this:
const arr1 = ["foo"];
const arr2 = arr1;
arr2.push("bar");
console.log(arr1 === arr2); // new elements are pushed but address remains the same
I'd recommend you to read more about shallow and deep copy in JS and how react decides when to re render its component.
Oh, you are creating activeItems using new Set() so when you perform activeItems.add you are mutating your existing state. And react doesn't detect that you have updated it event if you perform setActiveItems(selectedItems). Because selectedItems reference the same object as activeItems React under the hood just compare objects like references
if (obj1 !== obj2) {//run re-render cycle}
So you should create new Set when you are updating your state

React Memo resets values in component state

What I am trying to do
I'm trying to use an array of objects (habits) and render a "Card" component from each one.
Now for each Card (habit) you can "mark" that habit as done, which will update that Card's state.
I've used React.memo to prevent other Cards from re-rendering.
Whenever the user mark a card as done, the card header gets changed to "Edited"
The Issue I'm facing
Whenever a card is marked as done, the header changes alright, but whenever any other card is marked as done, the first card's state gets reverted back.
I've not been able to find other people facing a similar issue, can somebody please help?
Here is the code:
import React, { useState } from "react";
const initialState = {
habits: [
{
id: "1615649099565",
name: "Reading",
description: "",
startDate: "2021-03-13",
doneTasksOn: ["2021-03-13"]
},
{
id: "1615649107911",
name: "Workout",
description: "",
startDate: "2021-03-13",
doneTasksOn: ["2021-03-14"]
},
{
id: "1615649401885",
name: "Swimming",
description: "",
startDate: "2021-03-13",
doneTasksOn: []
},
{
id: "1615702630514",
name: "Arts",
description: "",
startDate: "2021-03-14",
doneTasksOn: ["2021-03-14"]
}
]
};
export default function App() {
const [habits, setHabits] = useState(initialState.habits);
const markHabitDone = (id) => {
let newHabits = [...habits];
let habitToEditIdx = undefined;
for (let i = 0; i < newHabits.length; i++) {
if (newHabits[i].id === id) {
habitToEditIdx = i;
break;
}
}
let habit = { ...habits[habitToEditIdx], doneTasksOn: [], name: "Edited" };
newHabits[habitToEditIdx] = habit;
setHabits(newHabits);
};
return (
<div className="App">
<section className="test-habit-cards-container">
{habits.map((habit) => {
return (
<MemoizedCard
markHabitDone={markHabitDone}
key={habit.id}
{...habit}
/>
);
})}
</section>
</div>
);
}
const Card = ({
id,
name,
description,
startDate,
doneTasksOn,
markHabitDone
}) => {
console.log(`Rendering ${name}`);
return (
<section className="test-card">
<h2>{name}</h2>
<small>{description}</small>
<h3>{startDate}</h3>
<small>{doneTasksOn}</small>
<div>
<button onClick={() => markHabitDone(id, name)}>Mark Done</button>
</div>
</section>
);
};
const areCardEqual = (prevProps, nextProps) => {
const matched =
prevProps.id === nextProps.id &&
prevProps.doneTasksOn === nextProps.doneTasksOn;
return matched;
};
const MemoizedCard = React.memo(Card, areCardEqual);
Note: This works fine without using React.memo() wrapping on the Card component.
Here is the codesandbox link: https://codesandbox.io/s/winter-water-c2592?file=/src/App.js
Problem is because of your (custom) memoization markHabitDone becomes a stale closure in some components.
Notice how you pass markHabitDone to components. Now imagine you click one of the cards and mark it as done. Because of your custom memoization function other cards won't be rerendered, hence they will still have an instance of markHabitDone from a previous render. So when you update an item in a new card now:
let newHabits = [...habits];
the ...habits there is from previous render. So the old items are basically re created this way.
Using custom functions for comparison in memo like your areCardEqual function can be tricky exactly because you may forget to compare some props and be left with stale closures.
One of the solutions is to get rid of the custom comparison function in memo and look into using useCallback for the markHabitDone function. If you also use [] for the useCallback then you must rewrite markHabitDone function (using the functional form of setState) such that it doesn't read the habits using a closure like you have in first line of that function (otherwise it will always read old value of habits due to empty array in useCallback).

Trying to use ref as an incrementing counter in a React component- gap in values occurs

I've reproduced the issue I'm having in this sandbox
I'm trying to merge two arrays of objects stored in state in a React component.
The second one is created asynchronously (in my example this is done by clicking the button once).
On each array item I want to add an "offset" value to each one which increments on each item in the array. To keep track of where the offset is up to, I assumed I need to use a ref.
However when I do this, a mysterious gap appears in offset values when the second array is added. The gap is related to the number of items in the second array. It's almost like the "useEffect" that merges the new arrays is being run twice and the first time is ignored. I've been trying to debug this for hours now and I can't work out where the gap is coming from.
What should I be doing to acheive my desired result?
I'm guessing it's something I don't understand about useRef() or perhaps I should be using another React Hook somewhere? Please help!
The demo code is as follows:
import { useState, useRef, useEffect } from "react";
import "./styles.css";
export default function App() {
const [array1, setArray1] = useState([{ id: 1 }, { id: 2 }, { id: 3 }]);
const [array2, setArray2] = useState([]);
const offset = useRef(0);
const handleClick = () => {
setArray2([{ id: 4 }, { id: 5 }, { id: 6 }]);
};
//handle Initial load
useEffect(() => {
setArray1((prevArray) => {
const newArray = prevArray.map((item) => {
offset.current = offset.current + 10;
return { ...item, offset: offset.current };
});
return [...newArray];
});
}, [setArray1]);
//merge the array 2 with array 1
useEffect(() => {
setArray1((prevArray) => {
const newArray = array2.map((item) => {
offset.current = offset.current + 10;
return { ...item, offset: offset.current };
});
return [...prevArray, ...newArray];
});
}, [setArray1, array2]);
return (
<div className="App">
<h1>Incrementing offset problem</h1>
<p>Offsets are not what I expected when adding new items to array</p>
<p>
I expect the offsets to continue in increments of one but instead there
is a gap of 3
</p>
<h4>Array 1</h4>
<ul>
{array1.map((item) => (
<li key={item.id}>
Item ID: {item.id}, Item offset: {item.offset}
</li>
))}
</ul>
<button onClick={() => handleClick()}>Add new 3 new items</button>
</div>
);
}

Categories