Immutable Deep State Updates in React with Ramda.js
Basic state updates in React are a breeze using setState
, but updating deeply nested values in your state can get a little tricky. In this post, I’m going to show you how you can leverage lenses in Ramda to handle deep state updates in a clean and functional way.
Let’s start with a simple counter component.
import React from 'react'
import { render } from 'react-dom'
class App extends React.Component {
constructor(props) {
super(props)
this.state = {
count: 0,
}
this.increase = this.increase.bind(this)
this.decrease = this.decrease.bind(this)
}
increase() {
this.setState((state) => ({ count: state.count + 1 }))
}
decrease() {
this.setState((state) => ({ count: state.count - 1 }))
}
render() {
return (
<div>
<button onClick={this.increase}>+</button>
<div>{this.state.count}</div>
<button onClick={this.decrease}>-</button>
</div>
)
}
}
render(<App />, document.getElementById('root'))
Here, we’re using a function as the argument for setState
and just incrementing or decrementing the count based on the passed in state value. This is fine for a simple property sitting at the top level of the state tree, but let’s update the shape of our state object and move that count
a little deeper into the state.
this.state = {
a: {
name: 'pointless structure',
b: {
stuff: 'things',
count: 0,
},
},
}
This new state
is incredibly contrived, but it’ll help illustrate the point. Now, in order to update the count, we need to update property a
, which in turn needs an updated b
and that will contain our updated count
. The update function for increase
will now need to look like this:
increase() {
this.setState((state) => ({a: {...state.a, b: {...state.a.b, count: state.a.b.count + 1}} }))
}
This works, but is not very readable. Let’s briefly look at what’s happening here.
The existing state is passed into the function, and we want to return an object that represents the object to be merged with state
. The setState
method doesn’t merge recursively, so doing something like this.setState((state) => ({a: {b:{ count: state.a.b.count + 1}}}))
would update the count, but the other properties on a
and b
would be lost. In order to prevent that, the returned object is created by spreading the existing properties of state.a
into a new object where we then replace b
. Since b
also has properties that we want to keep, but don’t want to change, we spread state.b
’s props and replace just count
, which is replaced with a new value based on the value in state.a.b.count
.
Of course, we need to do the same thing with decrease
, so now the entire component looks like this:
import React from 'react'
import { render } from 'react-dom'
class App extends React.Component {
constructor(props) {
super(props)
this.state = {
a: {
name: 'pointless structure',
b: {
stuff: 'things',
count: 0,
},
},
}
this.increase = this.increase.bind(this)
this.decrease = this.decrease.bind(this)
}
increase() {
this.setState((state) => ({
a: { ...state.a, b: { ...state.a.b, count: state.a.b.count + 1 } },
}))
}
decrease() {
this.setState((state) => ({
a: { ...state.a, b: { ...state.a.b, count: state.a.b.count - 1 } },
}))
}
render() {
return (
<div>
<h1>{this.state.a.name}</h1>
<h2>{this.state.a.b.stuff}</h2>
<button onClick={this.increase}>+</button>
<div>{this.state.a.b.count}</div>
<button onClick={this.decrease}>-</button>
</div>
)
}
}
render(<App />, document.getElementById('root'))
Those setState
calls are kind of a mess! The good news is, there’s a better way. Lenses are going to help us clean this up and get back to state updates that are both readable and clearly communicate the intent of the update.
Lenses allow you to take an object and “peer into it”, or “focus on” a particular property of that object. You can do this by specifying a path to put your focus on a property that is deeply nested inside the object. With that lens focused on your target, you can then set new values on that property without losing the context of the surrounding object.
To create a lens that focuses on the count
property in our state, we will use ramda’s lensPath
function and array that describes the path to count
, like so:
import { lensPath } from 'ramda'
const countLens = lensPath(['a', 'b', 'count'])
Now that we have a lens, we can use it with one of the lens-consuming functions available in ramda: view
, set
and over
. If we run view
, passing it our lens and the state object, we’ll get back the value of count
.
import { lensPath, view } from 'ramda'
const countLens = lensPath(['a', 'b', 'count'])
// somewhere with access to the component's state
view(countLens, state) // 0
Admittedly, view
doesn’t seem super useful since we could have just referenced the path to state.a.b.count
or use ramda’s path
function. Let’s see how we can do something useful with our lens. For that, we’re going to use the set
function.
import { lensPath, view, set } from 'ramda'
const countLens = lensPath(['a', 'b', 'count'])
// somewhere with access to the component's state
const newValue = 20
set(countLens, newValue, state)
When we do this, we’ll get back an object that looks like:
{
a: {
name: 'pointless structure',
b : {
stuff: 'things',
count: 20 // update in context
}
}
}
We’ve gotten back a new version of our state
object in which the value of state.a.b.count
has been replaced with 20
. So not only have we made a targeted change deep in the object structure, we did it in an immutable way!
So if we take what we’ve learned so far, we can update our increment
method in our component to look more like this:
increase() {
this.setState((state) => {
const currentCount = view(countLens, state)
return set(countLens, currentCount+1, state)
})
}
We’ve used view
with our lens to get the current value, and then called set
to update the value based on the old value and return a brand new version of our entire state
.
We can take this a step further. The over
function takes a lens and a function to apply to the target of the lens. The result of the function is then assigned as the value of that target in the returned object. So we can use ramda’s inc
function to increment a number. So now we can make the increase
method look like:
increase() {
this.setState((state) => over(countLens, inc, state))
}
Pretty cool, right?! Well, it gets even better… no, for real, it does!
All of ramda’s functions are automatically curried, so if we pass over
just the first argument, we get back a new function that expects the second and third arguments. If I pass it the first two arguments, it returns a function that expects the last argument. So that means that I can do this:
increase() {
this.setState((state) => over(countLens, inc)(state))
}
Where the initial call to over
returns a function that accepts state
. Well, setState
accepts a function that accepts state
as an argument, so now I can shorten the whole thing down to:
increase() {
this.setState(over(countLens, inc))
}
And if this doesn’t convey enough meaning for you, you can move that over
function out of the component and give it a nice meaningful name:
// outside of the component:
const increaseCount = over(countLens, inc)
// Back in the component
increase() {
this.setState(increaseCount)
}
And of course, the same can be done to the decrease
method using dec
from ramda. This would make the whole setup for this component look like this:
import React from 'react'
import { render } from 'react-dom'
import { inc, dec, lensPath, over } from 'ramda'
const countLens = lensPath(['a', 'b', 'count'])
const increaseCount = over(countLens, inc)
const decreaseCount = over(countLens, dec)
class App extends React.Component {
constructor(props) {
super(props)
this.state = {
a: {
name: 'pointless structure',
b: {
stuff: 'things',
count: 0,
},
},
}
this.increase = this.increase.bind(this)
this.decrease = this.decrease.bind(this)
}
increase() {
this.setState(increaseCount)
}
decrease() {
this.setState(decreaseCount)
}
render() {
return (
<div>
<h1>{this.state.a.name}</h1>
<h2>{this.state.a.b.stuff}</h2>
<button onClick={this.increase}>+</button>
<div>{this.state.a.b.count}</div>
<button onClick={this.decrease}>-</button>
</div>
)
}
}
render(<App />, document.getElementById('root'))
The nice thing here is that if the shape of the state changes, we can update our state manipulation logic just by adjusting the lensPath
. In fact, we could even use the lens along with view
to display our data in render
and then we could rely on that lensPath
to handle all of our references to count!
So that would mean this: {this.state.a.b.count}
would be replaced by the result of: view(countLens, this.state)
in the render
method.
So here it is with that final adjustment, take it for a spin and see what you can do with it!
Edit Ramda Lenses - React setState
import React from 'react'
import { render } from 'react-dom'
import { inc, dec, lensPath, over, view } from 'ramda'
const countLens = lensPath(['a', 'b', 'count'])
const increaseCount = over(countLens, inc)
const decreaseCount = over(countLens, dec)
class App extends React.Component {
constructor(props) {
super(props)
this.state = {
a: {
name: 'pointless structure',
b: {
stuff: 'things',
count: 0,
},
},
}
this.increase = this.increase.bind(this)
this.decrease = this.decrease.bind(this)
}
increase() {
this.setState(increaseCount)
}
decrease() {
this.setState(decreaseCount)
}
render() {
const count = view(countLens, this.state)
return (
<div>
<h1>{this.state.a.name}</h1>
<h2>{this.state.a.b.stuff}</h2>
<button onClick={this.increase}>+</button>
<div>{count}</div>
<button onClick={this.decrease}>-</button>
</div>
)
}
}
render(<App />, document.getElementById('root'))