Why does calling one `setState` function update an entirely separate state value? - javascript

I have an issue where one setState function is updating two separate state values.
I am trying to make a small React sortable array. The program should behave like so:
fetches data on mount
stores that data in both unsortedData & displayedData state hooks
when a user clicks "toggle sorting" button the array in displayedData is sorted (using .sort())
on a second click of "toggle sorting" it should set displayedData to be the same value as unsortedData - restoring the original order
However, on first click of toggle sorting, it sorts both unsortedData & displayedData, which means I lose the original data order. I understand I could store their order, but I'm wanting to know why these state values appear to be coupled.
Stackblitz working code here.
GIF showing issue here
I cannot find where this is happening. I can't see any object/array referencing going on (I'm spreading for new objects/arrays).
Code here:
const Test = () => {
const [unsortedData, setUnsortedData] = useState([])
const [displayedData, setDisplayedData] = useState([])
const [isSorted, setisSorted] = useState(false)
const handleSorting = () => setisSorted(!isSorted)
useEffect(() => {
if (isSorted === true) setDisplayedData([...unsortedData.sort()]) // sort data
if (isSorted === false) setDisplayedData([...unsortedData]) // restore original data order
}, [isSorted, unsortedData])
useEffect(() => {
const mockData = [3, 9, 6]
setUnsortedData([...mockData]) // store original data order in "unsortedData"
setDisplayedData([...mockData])
}, [])
return (
<div>
{displayedData.map(item => item)}
<br />
<button onClick={() => handleSorting()}>Toggle sorting</button>
</div>
)
}

The .sort function mutates the array, so when you do this:
setDisplayedData([...unsortedData.sort()])
You are mutating unsortedData, then making a copy of it afterwards. Since you mutated the original array, that change can display on the screen when the component rerenders.
So a minimum fix would be to copy first, and sort afterwards:
setDisplayedData([...unsortedData].sort())
Also, why do I need the useEffect? Why can't I just move the contents of the useEffect (that has the if statements in) into the handleSorting function?
Moving it into handleSorting should be possible, but actually i'd like to propose another option: have only two states, the unsorted data, and the boolean of whether to sort it. The displayed data is then a calculated value based on those two.
const [unsortedData, setUnsortedData] = useState([3, 9, 6]) // initialize with mock data
const [isSorted, setisSorted] = useState(false)
const displayedData = useMemo(() => {
if (isSorted) {
return [...unsortedData].sort();
} else {
return unsortedData
}
}, [unsortedData, isSorted]);
const handleSorting = () => setisSorted(!isSorted)
// No use useEffect to sort the data
// Also no useEffect for the mock data, since i did that when initializing the state
The benefits of this approach are that
You don't need to do a double render. The original version sets isSorted, renders, then sets displayed data, and renders again
It's impossible to have mismatched states. For example, in between that first and second render, isSorted is true, and yet the displayed data isn't actually sorted yet. But even without the double render case, having multiple states requires you to be vigilant that every time you update unsortedData or isSorted, you also remember to update displayedData. That just happens automatically if it's a calculated value instead of an independent state.

If you want to display sorted array to user only when the user presses the button, you could use this code statement (orignaly coppied from vue3 todo list)
const filters = {
none: (data) => [...data],
sorted: (data) => [...data].sort((a, b) => a - b),
}
const Test = () => {
const [unsortedData, setUnsortedData] = useState([])
const [currentFilter, setCurrentFilter] = useState('none')
useEffect(() => {
const mockData = [3, 9, 6]
setUnsortedData([...mockData])
}, [])
return (
<div>
<ul>
{/* All magic is going here! */}
{filters[currentFilter](unsortedData).map(item => <li key={item}>{item}</li>)}
</ul>
<br />
<button
onClick={() =>
setCurrentFilter(currentFilter === 'none' ? 'sorted' : 'none')}>
Toggle sorting
</button>
</div>
)
}

Related

With useState How do I set initial state to an empty Array, and then add empty Objects to that Array when I button is Clicked?

I want to mount a component which will mount children components based on how many times a user hits a + ingredient icon as shown here (this screenshot shows one hardcoded to render)
I want to achieve this by setting an ingredients state to initially be an empty array
const [ingredients, setIngredients] = useState([]);
And then have the onClick action of the plus icon add an empty object to this array, while preserving any objects that have been added/modified
const addElementToArray = () => {
setIngredients((prevIngredients) => [
...prevIngredients, {}
]);
}
However, the current behavior is a bit mysterious to me.
When console.log(ingredients) it is initially an empty array, great
However, when I click the + icon once it transforms to:
[{…}, [object Object]: {…}]
and then I believe back to an empty object
And finally, when I click it again the component crashes with the error:
Uncaught TypeError: prevIngredients is not iterable
I'm unsure how to successfully get the functionality I want, any help is appreciated.
Full file below:
const RecipeCreate = props => {
const navigate = useNavigate();
const [ ingredients, setIngredients ] = useState([]);
const [ recipeTitle, setRecipeTitle ] = useState('');
useEffect(() => {
console.log(ingredients)
renderIngredientComponents()
}, [ingredients])
//will render IngredientDetailsInput for each obj in ingredients
//and will be updated using callbacks from that child component
const renderIngredientComponents = () => {
if (ingredients.length > 0) {
return ingredients.map((index, ingredient) => {
return <IngredientDetailsInput
key={index}
position={index}
updateIngredientArray={updateIngredientArray}
removeIngredient={removeIngredient}
/>
})
}
}
//failing function
const addElementToArray = () => {
setIngredients((prevIngredients) => [
...prevIngredients, {}
]);
}
return (
<div>
<div>
<form onSubmit={e => handleSubmit(e)}>
<div>
<label>Recipe Title</label>
<input
type="text"
name="recipeTitle"
value={recipeTitle}
onChange={e => setRecipeTitle(e.target.value)}/>
</div>
<div>
<p>Ingredients</p>
{renderIngredientComponents()}
<div>
<p onClick={()=> addElementToArray()}>+ ingredient</p>
</div>
</div>
<button type="submit">Submit</button>
</form>
</div>
</div>
)
}
export default RecipeCreate;
There are a number of problems here:
setIngredients(ingredients.splice(ingredients[position], 1))
splice mutates the array it's called on - but ingredients is stateful, and state should never be mutated. Mutating state often results in confusing and unpredictable behavior, and should not be done in React.
splice returns the removed element, not the mutated array
You need
setIngredients(ingredients.filter((_, i) => i !== position));
Also
setIngredients(ingredients[position] = details)
I know the comment says it's not implemented yet, but it's also causing a bug - you're passing details to setIngredients, so that it's no longer an array.
setIngredients(ingredients[position] = details)
is equivalent to
ingredients[position] = details;
setIngredients(details)
I'm not sure what logic you want there, but it's causing problems - remove that part entirely until you implement it properly, else it'll mess up your state.

Array is initially empty, but after an entry in a text field which is used for filtering, it is full

I verushc an array from one component to another component.
The initial array is filled by a DB and is not empty.
If I try to map over the array in my second component, it is empty (length = 0);
However, after I wrote a value in a search box to filter the array, all articles appear as intended.
What is that about?
export default function Einkäufe({ alleEinkäufe, ladeAlleEinkäufe, url }) {
const [searchTerm, setSearchTerm] = React.useState("");
const [searchResults, setSearchResults] = React.useState(alleEinkäufe);
const listeFiltern = (event) => {
setSearchTerm(event.target.value);
};
React.useEffect(() => {
setSearchResults(alleEinkäufe);
}, []);
React.useEffect(() => {
const results = alleEinkäufe.filter((eink) =>
eink.artikel.toLowerCase().includes(searchTerm.toLowerCase())
);
setSearchResults(results);
}, [searchTerm]);
[...]
{searchResults.map((artikel, index) => {
return ( ... );
})}
}
The problem is with your useEffect hook that sets the list of searchResults, it's not rerun when alleEinkäufe property is updated. You need to add alleEinkäufe as it's dependency.
React.useEffect(() => {
setSearchResults(alleEinkäufe);
}, [alleEinkäufe]);
My bet is that the parent component that renders Einkäufe is initially passing an empty array which is used as searchResults state and then never updated since useEffect with empty dependencies array is only run once on the component's mount.
I would also advise you to use English variable and function names, especially when you ask for assistance because it helps others to help you.
Your search term intially is "". All effects run when your components mount, including the effect which runs a filter. Initially, it's going to try to match any article to "".
You should include a condition to run your filter.
React.useEffect(() => {
if (searchTerm) {
const results = alleEinkäufe.filter((eink) =>
eink.artikel.toLowerCase().includes(searchTerm.toLowerCase())
);
setSearchResults(results);
}
}, [searchTerm]);
BTW, "" is falsy.

useEffect break array of useState at first time

I am learning react hooks. I am having mock data js call "MockFireBase.js" as below:
const userIngredientsList = [];
export const Get = () => {
return userIngredientsList;
}
export const Post = (ingredient) => {
ingredient.id = userIngredientsList.length + 1;
userIngredientsList.push(ingredient);
return ingredient;
}
Then my react hooks component "Ingredients.js" will call this mock utilities as following details:
const Ingredients = () => {
const [userIngredients, setUserIngredients] = useState([]);
// only load one time
useEffect(() => { setUserIngredients(Get()); }, []);
const addIngredienHandler = ingredient => {
let responsData = Post(ingredient);
setUserIngredients(preIngredients => {
return [...preIngredients, responsData]
});
}
return (
<div className="App">
<IngredientForm onAddIngredient={addIngredienHandler} />
<section>
<IngredientList ingredients={userIngredients} />
</section>
</div>
);
)
}
When I added first ingredient, it added two (of course I get same key issue in console.log). Then I added second ingredient is fine.
If I remove the useEffect code as below, it will work good.
// only load one time
useEffect(() => { setUserIngredients(loadedIngredients); }, []);
I am wondering what I did anything wrong above, if I use useEffect
The problem is not in useEffect. It's about mutating a global userIngredientsList array.
from useEffect you set initial component state to be userIngredientsList.
Then inside addIngredienHandler you call Post(). This function does two things:
2a. pushes the new ingredient to the global userIngredientsList array`. Since it's the same instance as you saved in your state in step 1, your state now contains this ingredient already.
2a. Returns this ingredient
Then, addIngredienHandler adds this ingredient to the state again - so you end up having it in the state twice.
Fix 1
Remove userIngredientsList.push(ingredient); line from your Post function.
Fix 2
Or, if you need this global list of ingredients for further usage, you should make sure you don't store it in your component state directly, and instead create a shallow copy in your state:
useEffect(() => { setUserIngredients([...Get()]); }, []);

How to sort React components based on specific value?

I have React component. This components take 'units' - (array of objects) prop. Based on that I render component for each of item. I want to sort my components based on 'price' value, which is one of state items property. But when i trigger the sorting - state changes correctly but my components order not changing.
const SearchBoxes = ({units}) => {
const [unitsState, setUnitsState] = useState([])
useEffect(() => {
setUnitsState(units)
}, [units])
const sortByPrice = () => {
const sortedUnits = sort(unitsState).desc(u => u.price); // sorting is correct
setUnitsState(sortedUnits) // state is changing correctly
}
return (
<div>
{unitsState.map((u,i) => {
return <UnitBox key={u.price} unit={u} />
})}
</div>
)
}
Can somebody help me, please ?
Why my components order do not changing when the state is changing after sort triggering ?
You aren't calling sortByPrice anywhere--all you've done is to define the function. I haven't tried it, but what if you changed useEffect to:
useEffect(() => {
setUnitsState(sort(unitsState).desc(u => u.price));
}, [units])
Then you don't need the sort method at all.

React useEffect doesn't trigger sometimes when I update my list as its dependency

const mylist = [1,2,3,4,5];
useEffect(() => {
console.log(mylist)
}, [mylist])
This is part of my code. useEffect doesn't trigger when I append a new element to mylist, but it does when I delete an element.
How do I fix it so that it triggers when I append new elements to mylist
I have a button doing onClick(e => mylist.push(e))
and another one onClick(e => mylist.remove(e))
The array that you're creating isn't being stored in state, so every render a new array is being created. The solution is to use react state:
function MyComponent() {
const [myList, setMyList] = useState([0,1,2,3,4])
useEffect(() => {
console.log(myList)
}, [myList])
return (
<div>
{JSON.stringify(myList)}
<button onClick={() => setMyList([...myList, myList.length])}>Add</button>
</div>);
}
I couldn't make a comment on #Gerard's answer until I have more reputation points. I want to add that make sure you pass an arrow function to setMyList as shown:
function MyComponent() {
const [myList, setMyList] = useState([0,1,2,3,4])
useEffect(() => {
console.log(myList)
}, [myList])
return (
<div>
{JSON.stringify(myList)}
<button onClick={() => setMyList(prevList => [...prevList, prevList.length])}>Add</button>
</div>);
}
It worked when I change the dependency to mylist.toString()
I thought useEffect does deep comparison on the second parameter
You could check the length of the array so if the array size changes the effect will be excecuted:
useEffect(() => {
//Your effect
}, [mylist.length])
useEffect using strict comparison, but an array always comes up as false, so [1] === [1] is false and [1] === [1, 2] is still false.
It likely only runs on first render, that is why it's not updating it when you add or remove from list. Try putting the length of the array as a dependancy and it'll work as you intend it to.
So,
useEffect(() => {
//stuff on myList
}, [myList.length])
So if you add something to it, it'll be comparing integer to integer

Categories

Resources