createSlice automatically use Immer internally to let you write simpler immutable update logic using "mutating" syntax. This helps simplify most reducer implementations.
Because Immer is itself an abstraction layer, it's important to understand why Redux Toolkit uses Immer, and how to use it correctly.
"Mutable" means "changeable". If something is "immutable", it can never be changed.
This is called mutating the object or array. It's the same object or array reference in memory, but now the contents inside the object have changed.
In order to update values immutably, your code must make copies of existing objects/arrays, and then modify the copies.
Want to Know More?
One of the primary rules of Redux is that our reducers are never allowed to mutate the original / current state values!
There are several reasons why you must not mutate state in Redux:
- It causes bugs, such as the UI not updating properly to show the latest values
- It makes it harder to understand why and how the state has been updated
- It makes it harder to write tests
- It breaks the ability to use "time-travel debugging" correctly
- It goes against the intended spirit and usage patterns for Redux
So if we can't change the originals, how do we return an updated state?
Reducers can only make copies of the original values, and then they can mutate the copies.
This becomes harder when the data is nested. A critical rule of immutable updates is that you must make a copy of every level of nesting that needs to be updated.
A typical example of this might look like:
However, if you're thinking that "writing immutable updates by hand this way looks hard to remember and do correctly"... yeah, you're right! :)
Writing immutable update logic by hand is hard, and accidentally mutating state in reducers is the single most common mistake Redux users make.
Immer is a library that simplifies the process of writing immutable update logic.
Immer provides a function called
produce, which accepts two arguments: your original
state, and a callback function. The callback function is given a "draft" version of that state, and inside the callback, it is safe to write code that mutates the draft value. Immer tracks all attempts to mutate the draft value and then replays those mutations using their immutable equivalents to create a safe, immutably updated result:
createReducer API uses Immer internally automatically. So, it's already safe to "mutate" state inside of any case reducer function that is passed to
createReducer inside, so it's also safe to "mutate" state there as well:
This even applies if the case reducer functions are defined outside of the
createSlice/createReducer call. For example, you could have a reusable case reducer function that expects to "mutate" its state, and include it as needed:
This works because the "mutating" logic is wrapped in Immer's
produce method internally when it executes.
Remember, the "mutating" logic only works correctly when wrapped inside of Immer! Otherwise, that code will really mutate the data.
There are several useful patterns to know about and gotchas to watch out for when using Immer in Redux Toolkit.
Immer works by tracking attempts to mutate an existing drafted state value, either by assigning to nested fields or by calling functions that mutate the value. That means that the
state must be a JS object or array in order for Immer to see the attempted changes. (You can still have a slice's state be a primitive like a string or a boolean, but since primitives can never be mutated anyway, all you can do is just return a new value.)
In any given case reducer, Immer expects that you will either mutate the existing state, or construct a new state value yourself and return it, but not both in the same function! For example, both of these are valid reducers with Immer:
However, it is possible to use immutable updates to do part of the work and then save the results via a "mutation". An example of this might be filtering a nested array:
Note that mutating state in an arrow function with an implicit return breaks this rule and causes an error! This is because statements and function calls may return a value, and Immer sees both the attempted mutation and and the new returned value and doesn't know which to use as the result. Some potential solutions are using the
void keyword to skip having a return value, or using curly braces to give the arrow function a body and no return value:
While writing nested immutable update logic is hard, there are times when it is simpler to do an object spread operation to update multiple fields at once, vs assigning individual fields:
As an alternative, you can use
Object.assign to mutate multiple fields at once, since
Object.assign always mutates the first object that it's given:
Sometimes you may want to replace the entire existing
state, either because you've loaded some new data, or you want to reset the state back to its initial value.
A common mistake is to try assigning
state = someValue directly. This will not work! This only points the local
state variable to a different reference. That is neither mutating the existing
state object/array in memory, nor returning an entirely new value, so Immer does not make any actual changes.
Instead, to replace the existing state, you should return the new value directly:
It's common to want to log in-progress state from a reducer to see what it looks like as it's being updated, like
console.log(state). Unfortunately, browsers display logged Proxy instances in a format that is hard to read or understand:
To work around this, Immer includes a
current function that extracts a copy of the wrapped data, and RTK re-exports
current. You can use this in your reducers if you need to log or inspect the work-in-progress state:
The correct output would look like this instead:
Immer also provides
isDraft functions, which retrieves the original data without any updates applied and check to see if a given value is a Proxy-wrapped draft. As of RTK 1.5.1, both of those are re-exported from RTK as well.
Immer greatly simplifies updating nested data. Nested objects and arrays are also wrapped in Proxies and drafted, and it's safe to pull out a nested value into its own variable and then mutate it.
However, this still only applies to objects and arrays. If we pull out a primitive value into its own variable and try to update it, Immer has nothing to wrap and cannot track any updates:
There is a gotcha here. Immer will not wrap objects that are newly inserted into the state. Most of the time this shouldn't matter, but there may be occasions when you want to insert a value and then make further updates to it.
Related to this, RTK's
createEntityAdapter update functions can either be used as standalone reducers, or "mutating" update functions. These functions determine whether to "mutate" or return a new value by checking to see if the state they're given is wrapped in a draft or not. If you are calling these functions yourself inside of a case reducer, be sure you know whether you're passing them a draft value or a plain value.
Finally, it's worth noting that Immer does not automatically create nested objects or arrays for you - you have to create them yourself. As an example, say we have a lookup table containing nested arrays, and we want to insert an item into one of those arrays. If we unconditionally try to insert without checking for the existence of that array, the logic will crash when the array doesn't exist. Instead, you'd need to ensure the array exists first:
Many ESLint configs include the https://eslint.org/docs/rules/no-param-reassign rule, which may also warn about mutations to nested fields. That can cause the rule to warn about mutations to
state in Immer-powered reducers, which is not helpful.
To resolve this, you can tell the ESLint rule to ignore mutations to a parameter named
See the Immer documentation for more details on Immer's APIs, edge cases, and behavior.