State in React is Immutable
What does that even mean?
See Our Public Workshops:
tldr;
When you set state in React, a new value must be passed or React will bail-out and not *re-render your component. React uses something similar to ===
to compare the old and new state to see if they're the same. They say in docs they use Object.is() instead of ===
but either way, they're practically the same. Both would do shallow equality checks and this is why objects and arrays cannot simply be mutated to create a state change because a mutation will not create a new reference for React to notice a change. React requires immutability because you'll create a whole new copy of objects and arrays with your changes in order to satisfy a shallow equality check.
* There is a unique case when you might set a value to be the same as it already was and React will do a quick render of that component without deeply rendering it's children. This is probably for "React internals" and you'll likely not notice or ever encounter this.
What are mutations?
Objects and Arrays in JavaScript can be mutated:
const user = {}user.name = 'brad' // mutation (changed the user object)
Can we mutate a const
?.
Yes. Using const
just means the variable cannot be re-assigned a "new value":
const user = {}user.name = 'brad' // This is a mutation because we're mutating the objectuser = 9 // not allowed, re-assignment of a constant to a new valueuser = { name: 'brad' } // not allowed, re-assignment of a constant to a new value
Constants can't be re-assigned. This isn't to be confused with "mutating or not mutating". Re-assignment has more to do with the variable user
and mutation has more to do with the value.
In our case, we mutate the object (the value) but we cannot re-assign the constant. These are unrelated things.
Let's look at another example where we mutate an array of objects by removing something:
const users = [{ id: 1, name: 'michael' },{ id: 2, name: 'brad' }, // <-- let's remove{ id: 3, name: 'ryan' },]const index = users.findIndex((u) => u.id === 2) // find brad's index// Mutation: The splice method can be used to add or remove from an array// Now the users array just has michael and ryan in it.users.splice(index, 1)
There's nothing wrong with the general idea of mutations. We do this kind of thing all the time. So why do some people suggest mutations are bad? What they're talking about is the general theory that for application data that changes over time, sometimes it can be problematic to do mutations which can create bugs. They might prefer to do an "immutable strategy" instead for some of their data. But everyone does mutations some of the time.
What does it mean to be immutable?
When we say we want to do immutability, this does not mean that our data never changes. In fact quote the opposite. All application data needs to change over time, but doing immutability is just the technique to which we decide to change it.
Instead of mutating the array, we what if we replace the original array with a new one that has the changes we want. In other words, we:
- Make a copy of the original
- Make changes to the copy
- Replace the original with the copy
This is immutability
In general you can decide to use mutability or immutability, but React requires state to be immutable. In other words, we don't mutate state if we want to change it, instead we make a copy of it and replace the old state with the new copy - that's immutability.
Here is the same user collection example from above but with immutability. Notice it's more complex to use immutability:
// We still need to find the indexconst index = users.findIndex((u) => u.id === 2)// Here's the immutable part:// 1. Make a copy of the original// 2. Make changes to the copy// 3. Replace the original with the copyconst newArray = [...users.slice(0, index), ...users.slice(index + 1)]
When we do const newArray = []
, we're already committed to making a new array and not mutating the old one. Then we populate the new array with sliced parts of the old array. Doing slice
will make a copy of an array so we can see here that we're copying all the parts of the original array up to the index that we want to remove. Then we're copying all the parts after the index. The net result is a new array with the changes we wanted -- the removal of user 2
Like I said, it's more complex than mutations.
Here's another example just to firm up the concept:
const person = { name: 'brad', occupation: 'web' }changeOccupation(person, 'web developer') // <-- Let's write this function below:function changeOccupation(person, occupation) {// If we did this, it would be a mutation:person.occupation = occupationreturn person}// But if we did any of these, they would be immutable:// Immutable Option Onefunction changeOccupation(person, occupation) {return Object.assign({}, person, { occupation: occupation })}// Immutable Option Two (Newer Way)function changeOccupation(person, occupation) {return { ...person, occupation: occupation }}
With option one, Object.assign()
, takes any number of objects and merges them from right to left into the object on the far left. Then it returns that new object. By doing an blank new object at the beginning, we set the stage for immutability by having the new occupation blended into the old object (not mutating it though) and all assigned to the new empty object. Object.assign()
is considered the "older strategy" for this sort of thing.
With option two, we're returning a new object while spreading a shallow copy of all the parts of the old object (which is fine, we don't need a deep copy to be immutable). The spread of the original goes first so we can make any changes to our new object on the right. This way is considered more modern than Object.assign()
but they do the same thing really.
Primitives are technically immutable in JavaScript
Primitives like numbers, strings, and booleans are always technically immutable in JavaScript. This is how the programming language works at a low level. Doing this next example, while it might look like a mutation based on what we've already said above, is actually technically not:
let x = 1x = 2
From the MDN Docs
All primitives are immutable; that is, they cannot be altered. It is important not to confuse a primitive itself with a variable assigned a primitive value. The variable may be reassigned to a new value, but the existing value can not be changed in the ways that objects, arrays, and functions can be altered. The language does not offer utilities to mutate primitive values.
Many devs don't think about mutable vs immutable for primitives though. It does feel like a mutation after all. Usually when we're talking about being "immutable", we're talking about objects and arrays.
Why is React immutable?
For this section, we assume you know a little thing or two about how React state works with useState
and re-rendering.
React wants a new value when you set state. That's now it knows via via equality checks whether or not to do a re-render or to bail out and skip the re-render.
Imagine if we tried to mutate an array in our state with push()
:
function App() {const [users, setUsers] = useState([{ id: 1, name: 'michael' },{ id: 2, name: 'brad' },{ id: 3, name: 'ryan' },])function addUser(newUser) {// Try push (mutation)users.push(newUser)}return (<div><AddUserForm onSubmit={addUser} /><ShowUsers users={users} /></div>)}
Normally when you think of "adding to an array" you think of .push()
. There's nothing wrong with .push()
in general, but it is a mutation and that is breaking the rule of "mutating state". Does that mean react crashes now?
React isn't going to stop you from mutating your state, you're just not going to get the desired outcome which would be a re-render.
Let's say our next move is to setState
to cause a re-render:
function addUser(newUser) {users.push(newUser)// Now let's set state with users since it's// now a bigger array. Hopefully this causes// a re-render 🤞setUsers(users)}
It doesn't work 😩
Again, React practically forces you to do immutability because they need a new value. If you had an array of 3 things and then push a 4th thing, the array reference remains the same. When you setState with that same array reference, React does a shallow equality check not a deep dive into the items in your array. They just check to see if:
oldArray !== newArray
With the code above, the arrays are the same reference so React doesn't re-render. When objects and arrays (and functions) are compared to each other, they're compared by identity reference so you'll always need to:
- Make a copy of the original
- Make changes to the copy
- Replace the original with the copy
In other words, you must do immutability:
function addUser(newUser) {// This works (new array)setUsers([...users, newUser])}function addUser(newUser) {// This also workssetUsers(users.concat(newUser))}
The concat
method looks like push but it actually allows us to make a copy of an array and return the copy with the new value added, according to MDN:
This method does not change the existing arrays, but instead returns a new array.
This is why you see React developers do this sort of thing to change the state of objects:
setMyObject({ ...myObject, newStuff })setMyObject(Object.assign({}, myObject, newStuff))
What about primitives and state?
Let's say we want a simple counter. This one doesn't work because we're not calling setCount
to create a re-render. It looks like the developer is just changing the count variable directly and expecting to see the new value in the JSX?
function Counter() {let [count, setCount] = useState(0)function add() {count++}return <button onClick={add}>{count}</button>}
What if we did this to cause a re-render:
function Counter() {let [count, setCount] = useState(0)function add() {count++setCount(count)}return <button onClick={add}>{count}</button>}
This will actually work and will create a re-render. It works because we're setting the count with a different number and when React does the equality check, they're comparing primitives by value and the value changed.
We're not supposed to do count++
though because we're not supposed to mutate state. It works in this case but for confusing reasons and might give some the impression that mutations are okay. What we should have done is setCount(count + 1)
which more clearly shows that we give React our next state value without changing it directly.
I know we said "primitives are not technically mutable", but conversationally devs will still say (and think of) count++
or count = count + 1
as a mutation. Maybe we should more accurately say "Don't change state directly". In other words, create re-renders with a new value to get new state instead of changing directly.
Can we mutate props?
Props should be considered read-only and immutable. The props in your component are probably state in the component above you. Mutating props will have no effect on anything just like mutating state doesn't cause re-renders. You might even trick yourself into thinking that your props mutation does something you want, but it will be a source of bugs. Don't do it.
What is a "Mutable Ref"?
In a grander sense, state is really any value that changes over time in React. useState
and useReducer
are one way to make state, but there are others.
What if you want a value that changes over time but when you change it, you don't want a re-render. In that case you might want "mutable ref":
const myCount = useRef(0) // returns { current: 0 }function onClick() {myCount.current++ // mutate it when you want to}
This is a blessed feature of refs in React and while it feels hacky at first if your notion of a ref is "something we use to get access to the DOM", it is nice to have when you need it. It's called a mutable ref because you quite literally mutate the .current
property as you need to. Mutating will not cause a re-render but when you do eventually get re-renders, this useRef
will return the latest value you gave it. Therefore, it's like state -- a variable that can change over time between renders.
How about complex immutability
Sometimes you might have deep arrays or objects and doing immutability is complex. There are libraries out there to help you like ImmerJS.
Alternatively, we can do some little tricks of our own. We could make a deep copy (clone) of the original thing and once we have a copy, we can technically "mutate" the copy and then replace the original value with that copy. We might be mutating the copy but the net strategy still has the same immutable feeling because you never mutate the original.
Remember the previous example with the person
object? Let's do some tricks to copy the original and return a whole new deep clone with our changes:
const person = { name: 'brad', occupation: 'web' }changeOccupation(person, 'web developer') // <-- see how to write this belowfunction changeOccupation(person, occupation) {const personCopy = JSON.parse(JSON.stringify(person))personCopy.occupation = occupationreturn personCopy}
This is the "JSON serialization trick". It serializes our object into a string and then deserializes back to an object. The result is a deep clone of the object and therefore personCopy
references something different than person
. I would say that changeOccupation()
does an immutable strategy because the net result is that it takes person
and gives you a new person
reference with the changes. Even though we mutate the copy on the inside (see, these ideas can blend).
For arrays you can also do myarray.slice()
with no arguments to make a copy (and a whole new reference).
One of the problems with these deep clone strategies is the overhead of deep cloning a bunch of stuff that will not need to change. However, if I know my data and I know that it's small and the overhead is miniscule, then doing a deep clone might not matter much in terms of performance and it might make my code a LOT easier to read and write.
The JSON trick was popular for a while and was actually considered to be pretty fast compared to other similar deep clone algos. But now we have structuredClone() in JavaScript which is an official way to do it. I don't have data on this but I would imagine since it's native to JavaScript would be faster than any other home-grown approach.
It's a tool, use it if you need to.
What about global state?
With context for global state, we still use things like useState
and useReducer
as our state which will be immutable.
For Redux, they manage state outside of the React tree but they also embrace immutability by looking for state changes via ===
or Object.is()
MobX itself doesn't use immutability on its state. They use mutable variables that are also observables. An observable is a variable that when you mutate it, other places can be subscribed to know when the mutation happens. But they still have to conform to how React works and create some sort of local state change in order for React to get a re-render.
Under the hood they're doing a common trick where you force a re-render by changing state to be a new array or object:
const [, setState] = useState()function forceUpdate() {setState([]) // always a new array, so it will cause a re-render}
So while MobX doesn't use immutability, it does eventually need this trick to work. I guess we could say that and the end of the day, they still need an immutable array to work with React 🤔
The end.
Instead of having comments here in our blog, we've tweeted about this post so you can comment there if you wish.
View on Twitter