The setup
We have two components. A parent component (Main), where some tag list resides. And a child component (TagList) that receives a tag list, each tag is rendered as a removable item.
A first approach could be something like the following.
Main Component
const Main = () => {
const tags = ['one', 'two', 'three']
return <TagList tags={tags} />
}
TagList Component
const TagList = (props = {}) => {
const [tags, setTags] = useState(props.tags ?? [])
const handleDeleteTag = index => {
tags.splice(index, 1)
setTags(tags)
}
const handleReset = () => setTags(props.tags)
return (
<div>
{props.tags.map((tag, i) => (
<div key={i}>
<span>{tag}</span>
<input type="button" value="x" onClick={() => handleDeleteTag(i)} />
</div>
))}
<input type="button" value="Reset" onClick={handleReset} />
</div>
)
}
Expectations
When the user clicks on an 'x' marked button, the corresponding tag on that line is removed.
When the user clicks on the reset button, after having made some changes to any item. The initial list should be displayed.
Results
If we run that code, we will notice that no matter which button is pressed, nothing seems to be happening.
But behind the scenes, if we open the "Components" tab of Google Chrome devtools, (it may be needed to unselect and reselect components) we notice something pretty interesting.
The TagList component state data have been updated, but props data have been modified too on both Components.
State changed but no update was triggered
Because the updated state object passed to the setTags
is the variable provided by useState(props.tags)
, the same reference is detected, thus re-render operation is not triggered.
Components props data are modified
The useState and setTags methods passes its argument reference to the variable. In our case, it causes props.tags to mutate since it is passed as an argument on useState and setTags methods.
const [tags, setTags] = useState(props.tags ?? [])
const handleReset = () => setTags(props.tags)
Fix the issues
Reflect state updates
As we noticed, a state updated with a parameter that carries the same reference, won't cause a component to re-render. To fix the issue, we need to pass an argument with a different reference.
We'll make use of the ES6 spread operator to create a new array from updated tags.
const handleDeleteTag = index => {
tags.splice(index, 1)
setTags([...tags])
}
Prevent props to be changed
Since we know that useState
and setTags
mutates its passed parameter. We need to pass data in a way that doesn't lead props object to change.
const tagsInitialState = [...(props?.tags ?? [])]
const [tags, setTags] = useState(tagsInitialState)
const handleReset = () => setTags(tagsInitialState)
If you haven't been following on the latest ECMAScript specifications, that line may seem a bit tricky.
const tagsInitialState = [...(props?.tags ?? [])]
That line can be converted to.
const hasTags = props && props.tags && props.tags.length
const tagsInitialState = hasTags ? [...props.tags] : []
Final code
Our final TagList component code now looks like this
const TagList = (props = {}) => {
const tagsInitialState = [...(props?.tags ?? [])]
const [tags, setTags] = useState(tagsInitialState)
const handleDeleteTag = index => {
tags.splice(index, 1)
setTags([...tags])
}
const handleReset = () => setTags(tagsInitialState)
return (
<div>
{tags.map((t, i) => (
<div key={i}>
<span>{t}</span>
<input type="button" value="x" onClick={() => handleDeleteTag(i)} />
</div>
))}
<input type="button" value="Reset" onClick={handleReset} />
</div>
)
}
I hope that helps!
Feel free to share thoughts in the comment section!
Top comments (5)
I just want to say really good post! I like how you broke down the problem.
I wanted to point out that the useState does not mutate state, however, splice does mutate state. What you could do here is use slice which does the same thing as
splice
but without mutating the original array. And then you are correct that when callingsetTags
you want to create a new array. πBTW I didn't know about that the
?.
& the??
features! Going to start using it tomorrow at work! Thanks! πconst tagsInitialState = [...(props?.tags ?? [])]
Thanks for your feedback! Your explanation about splice and slice is totally right and is another way to solve the state update issue.
I have been reproducing the argument variable mutation in a straightforward example on codesandbox.io to demonstrate that useState initialization passes its argument reference to output value. I have tried to find the cause in the source code, but I'm having a hard time finding out. I would appreciate any help or insight on that. :D
The optionnal chaining operator (?.) and nullish coalescing operator (??) are both currently stage 4 proposal. Make sure you are on the latest Babel version (at least 7.8.0)
It makes code really more predictable π
In the example, you are mutating the original array. The push method mutates the original array.
The reason why your example is working is that when you call
updateComp
, react triggers a re-render because there is a state change and thus your UI reflecting the changes to the array.What you need to before updating an array is to make a copy of it so that you don't modify the original array. Then you can push/remove items in the clonedArray. and then you use the
setValue
method to update thevalue
state. I reflected this in the code below using your example.Congrats on your first post!! Very well redacted, the content is especially useful for what I do.
Thanks for your encouragement! I am happy I could help.