Mutation-like immutability in JavaScript using immer.js
Mutation-like immutability in JavaScript using immer.js
Immutability in JavaScript is important for the predictability and performance of your application. The idea of immutability is that once something is set, it doesn't change. But to perform operations, sometimes mutability is required. So how do we balance the need for mutability but keep the original object or array untouched?
If you're working with Redux, it's important to keep in mind that reducers are pure functions. A pure function is a type of function that only returns a specific expected result based on an input. This means that if x
is the input, it will always return y
.
A reducer takes the previous state and an action and return the next state. Why is this important? This is because a reduce returns a new state object rather than mutating the previous state.
Here is an example piece of code that shows what you cannot do in a reducer:
// A todo reducer which directly // mutates the original state function todos(state = [], action) { switch (action.type) { case 'ADD_TODO': state.push({ text: action.text, completed: false }) return state default: return state } }
In the ADD_TODO
action, pushing a todo object into the state using the push method causes mutation to the original state
. This makes the reducer impure because it changed the original object.
For a reducer to remain pure, you'd need to prevent change on the original object's state
. This is where enforcing immutability comes in.
Let's take a look at the following object:
const User = { name: 'Cody', age: 25, education: { school: { name: 'School of Code' } } }
If we want to maintain immutability and update education.school.name
, we can do so by making a copy of this object. One way to achieve this is by using the object spread. Here is an example:
const updatedUser = { ...User, education: { ...User.education, school : { ...User.education.school, name: 'Layercode Academy' } } }
In the code sample above, we created a copy of the User
object and changed the education.school.name
using object destructuring. While this gets the job done, it's hard to read. We'd need to go deeper into the object tree to change the property.
As the object grows, so does the complexity of creating copies to preserve immutability.
However, what if you could just mutate the object immutability rules? For example, what if we could write our JavaScript to look like something printed below:
User.education.school.name = 'Layercode Academy';
This is where immer.js comes in.
What is immer.js?
immer.js is a library that allows you to work with immutable states without breaking immutability itself. This is achieved through a copy-on-write mechanism. Here is a three-step process flow of how immer.js works:
- create a
draftState
which is a temporary state of your object'scurrentState
- apply all the changes to this
draftState
- return the final state based on the
draftState
while still keeping the original object unchanged.
immer.js uses a special JavaScript object called Proxy to wrap the data you provide, and lets you write code that "mutates" that wrapped data. The advantage this brings is you can interact with your data by simply modifying it while keeping all the benefits of immutable data.
Installing immmer.js
You can install immer.js via npm
or yarn
.
Using npm
:
$ npm install immer
Using yarn
:
$ yarn add immer
If your application is not using any of these package managers, you can directly add immer.js to your application through CDN like so.
- Unpkg:
<script src="https://unpkg.com/immer"></script>
- JSDelivr:
<script src="https://cdn.jsdelivr.net/npm/immer"></script>
Using immer.js
Once installed, you can start using immer.js right away.
The immer package exposes a default function called produce
that does all the work and is a source of truth. As the name of the function suggests, it "produces" the nextState
based on the currentState
of your object/array.
Here's how the definition of produce
function looks like.
produce(currentState, producer: (draftState) => void): nextState
It accepts two parameters:
currentState
- the current state of the object/array.producer
- an anonymous function that receives thedraftState
as its only argument and on which you can perform all the operations.
If we want to rewrite our previous example using immmer.js, we can do it like so.
import produce from "immer" const User = { name: 'Cody', age: 25, education: { school: { name: 'School of Code' } } } const updatedUser = produce(User, draftUser => { draftUser.education.school.name = 'Layercode Academy'; })
The above method is much easier to deal with than using nested spread operators. We are now able to copy and mutate the updatedUser
object using traditional JavaScript syntax without impacting the source.
Here is the original syntax for comparison.
const updatedUser = { ...User, education: { ...User.education, school : { ...User.education.school, name: 'Layercode Academy' } } }
You can also work with arrays the same way objects. Here is an example:
import produce from "immer" const todosArray = [ {id: "001", done: false, body: "Take out the trash"}, {id: "002", done: false, body: "Check Email"} ] const addedTodosArray = produce(todosArray, draft => { draft.push({id: "003", done: false, body: "Buy bananas"}) }) console.log(addedTodosArray) /* [ { id: '001', done: false, body: 'Take out the trash' }, { id: '002', done: false, body: 'Check Email' }, { id: '003', done: false, body: 'Buy bananas' } ] */
About useImmer
hook
If you're working with functional components in your React.js application, you can use this complementary hook called useImmer
to manipulate state inside your functional components.
To use userImmer
, you will need to install it, in addition to having immer.js
.
$ npm install use-immer
Once installed, you can start using the useImmer
hook in your application just like the useState
hook. With useImmer
, you can directly mutate the state inside the function that updates the state.
Here is an example:
import React from "react"; import { useImmer } from "use-immer"; function App() { const [person, updatePerson] = useImmer({ name: "Cody" }); function updateName(name) { updatePerson(draft => { draft.name = name; }); } return ( <div className="App"> <h1> Hello {person.name} </h1> <input onChange={e => { updateName(e.target.value); }} value={person.name} /> </div> ); }
The updatePerson
that we get from useImmer
sits on top of immer.js. This means you can directly mutate the state inside updatePerson
just like how you would do using the produce
function of immer.js.
Simplifying Redux reducers using immer.js
Using the curried producers of immer.js, you can greatly simplify your redux reducer logic.
Passing a function as the first argument to produce is intended to be used for currying. This means that you get a pre-bound producer that only needs a state to produce the value from. The producer function gets passed in the draft and any further arguments that were passed to the curried function.
Here is an example:
const INITIAL_STATE = {} const byId = (state = INITIAL_STATE, action) => { switch (action.type) { case RECEIVE_PRODUCTS: return { ...state, ...action.products.reduce((obj, product) => { obj[product.id] = product return obj }, {}) } default: return state } }
You can simplify this reducer by using immer's curried producers like so.
import produce from "immer" const INITIAL_STATE = {} const byId = produce((draft, action) => { switch (action.type) { case RECEIVE_PRODUCTS: action.products.forEach(product => { draft[product.id] = product }) break } }, INITIAL_STATE)
Here, draft
is the copy of the original state and action
will be the regular action that this reducer will receive.
As you can tell, you can also provide an initial state (INITIAL_STATE
) as the second argument to the produce
function. This lets you avoid writing the default
case.
immer.js is very powerful through its ability to help us simplify our code without sacrificing functionality. Overall, it can reduce the amount of code we need to write, maintain code comprehension and ensure that the code we write do not accidentally introduce side effects through the mutability of the original array or object.