The new hotness in immutable state management is Immer, a lightweight package designed to make operating on immutable objects a breeze.
Using Immer is like having a personal assistant; he takes a letter (the current state) and gives you a copy (draft) to jot changes onto. Once you are done, the assistant will take your draft and produce the real immutable, final letter for you (the next state). - Immer
I had a lot of fun refactoring a Redux app to use Immer, so I wanted to share how easy it really is!
Here is an example of a “standard” user reducer:
const initialState = {
meta: {
loading: true,
error: false
},
data: []
}
export default (state=initialState, action={}) => {
switch (action.type) {
case 'USERS_LOAD':
return {
...state,
meta: {
...state.meta,
loading: true,
error: false
}
}
case 'USERS_LOAD_SUCCESS':
return {
...state,
data: [...action.payload.data],
meta: {
...state.meta,
loading: false,
error: false
}
}
case 'USERS_LOAD_FAILURE':
return {
...state,
meta: {
...state.meta,
loading: false,
error: action.payload.error
}
}
default:
return state
}
}
This should seem very familiar. We have a function that accepts the current state
and an action
as arguments and returns a new state
copy with alterations based on action.type
and an optional action.payload
. We see a lot of object rest spreads (i.e. the ellipses or ...
), which can become verbose and error-prone when we get into larger nested structures. One could argue that each state managed by a reducer should have a flat data structure, but in practice that is a rare occurrence.
Immer allows us to simplify this pattern by operating on a draft
copy of the state as if it is mutable. To see what that looks like, let’s refactor this reducer.
First, will import the produce
function and put the reducer and initialState
in as the arguments of the produce
call.
import produce from 'immer'
const initialState = {
meta: {
loading: true,
error: false
},
data: []
}
export default produce( (state, action={}) => { switch (action.type) {
case 'USERS_LOAD':
return {
...state,
meta: {
...state.meta,
loading: true,
error: false
}
}
case 'USERS_LOAD_SUCCESS':
return {
...state,
data: [...action.payload.data],
meta: {
...state.meta,
loading: false,
error: false
}
}
case 'USERS_LOAD_FAILURE':
return {
...state,
meta: {
...state.meta,
loading: false,
error: action.payload.error
}
}
default:
return state } }, initialState
)
Next, we’re going to rename state
to draft
. This is just so we can stick with the Immer’s concept of manipulating a “draft state”. For more context, check out the Immer docs.
import produce from 'immer'
const initialState = {
meta: {
loading: true,
error: false
},
data: []
}
export default produce(
(draft, action={}) => { switch (action.type) {
case 'USERS_LOAD':
return {
...draft, meta: {
...draft.meta, loading: true,
error: false
}
}
case 'USERS_LOAD_SUCCESS':
return {
...draft, data: [...action.payload.data],
meta: {
...draft.meta, loading: false,
error: false
}
}
case 'USERS_LOAD_FAILURE':
return {
...draft, meta: {
...draft.meta, loading: false,
error: action.payload.error
}
}
default:
return draft }
},
initialState
)
In order to manipulate state within the produce
function, we just need to identify the changes we actually want to make. Let’s take the first original switch case as an example:
case 'USERS_LOAD':
return {
...state,
meta: {
...state.meta,
loading: true,
error: false
}
}
What values are really changing? Just state.meta.loading
and state.meta.error
.
With Immer, we can represent these changes by simply operating on the draft
state like it is mutable and the produce
function will return a read-only copy without us needing to explicitly return anything.
case 'USERS_LOAD':
draft.meta.loading = true
draft.meta.error = false
return
Since we don’t need to return any data within the produce
callback, we can skip the default
case too. The entire refactor will look like this:
import produce from 'immer'
const initialState = {
meta: {
loading: true,
error: false
},
data: []
}
export default produce(
(draft, action={}) => {
switch (action.type) {
case 'USERS_LOAD':
draft.meta.loading = true
draft.meta.error = false
return
case 'USERS_LOAD_SUCCESS':
draft.data = action.payload.data
draft.meta.loading = false
draft.meta.error = false
return
case 'USERS_LOAD_FAILURE':
draft.meta.loading = false
draft.meta.error = action.payload.error
return
}
},
initialState
)
The draft
is actually a proxy of the current state. Based on the changes to the draft
, Immer will determine which parts of the state can be re-used and which require a new copy.
Conclusion
What do you think? Does this look better or worse, simpler or more more complex? To me, this is definitely a smaller, more concise reducer. If you want to learn more about this approach, I recommend checking out the curried produce
section of the Immer docs.
Top comments (0)