In React, to update the interface, the standard way is to replace existing state with new state. It's a classic rookie mistake to modify data in place, but it's also a mistake to make deep copies while performing updates.
For state that includes objects and arrays, performing an update requires replacing the path of parent objects and arrays that contain the field.
const objects = [{ field: 1 }]
let nextObjects = objects.slice()
nextObjects[0] = {
...objects[0],
field: 2,
}
setObjects(nextObjects)
It's a little verbose to copy the array and copy the object when all you want to do is update a single field. So developers will sometimes take a shortcut using the lodash library.
const objects = [{ field: 1 }]
// don't do this
let nextObjects = _.cloneDeep(objects)
nextObjects[0].field = 2
For small apps, it works fine. For large apps, it freezes the page.
The reason _.cloneDeep
does not scale is that it breaks equality comparisons by reference.
In React the contract between a parent component and child component, when passing props, is that if the reference is the same the data is assumed to be the same, and vice versa. It's a performance optimization that allows fast equality checks.
// Fast.
previousValue === nextValue
// Varies from fast to slow.
_.isEqual(previousValue, nextValue)
Comparison by reference, if done right, allows you to memoize child components that receive objects or arrays as props.
It can help to see the effect with a real world example. Here is a sequence of changes and renders for an example app that renders a blog post with comments.
const [comments, setComments] = useState([])
BlogPost <= render
CommentList <= render
setComments([
{ id: 5, body: 'Nice post!' },
{ id: 8, body: 'Terrible code, this is all wrong.' },
])
BlogPost <= render
CommentList <= render
CommentItem <= render
CommentItem <= render
In the next change, only the new comment will render again.
setComments([
...comments,
{
{id: 12, body: 'Yikes, which part is wrong?', replyTo: 8},
},
]);
BlogPost <= render
CommentList <= render
CommentItem
CommentItem
CommentItem <= render
In the next change, only the updated comment will render again.
const nextComments = comments.slice()
const index = nextComments.findIndex((comment) => comment.id === 8)
nextComments[index] = {
...comments[index],
body: 'Edit: Actually the code looks fine.',
}
setComments(nextComments)
BlogPost <= render
CommentList <= render
CommentItem <= render
CommentItem
CommentItem
It's not dramatic with only a few list items, however in practice some apps have hundreds or even thousands of list items.
The problem with _.cloneDeep
is that it replaces all of the references.
// don't do this
const nextComments = _.cloneDeep(comments)
const index = nextComments.findIndex((comment) => comment.id === 8)
nextComments[index].body = 'Edit: It keeps freezing on me.'
setComments(nextComments)
BlogPost <= render
CommentList <= render
CommentItem <= render
CommentItem <= render
CommentItem <= render
// don't do this
const nextComments = _.cloneDeep(comments)
nextComments.push({
id: 14,
body: 'Found it! The slowness is due to clone deep.',
})
setComments(nextComments)
BlogPost <= render
CommentList <= render
CommentItem <= render
CommentItem <= render
CommentItem <= render
CommentItem <= render
It's fine for small apps because you can afford to render everything over and over, but for large apps it breaks the page because it's too much work to render everything.
This article has been updated for ECMA Script 2019 and beyond. There is a builtin function now for making deep copies of objects called structuredClone
. It's the same as lodash cloneDeep
in that it creates new object references for everything and it should never be used in React apps. Never structuredClone
.