Posted on • Edited on • Originally published atvanslaars.io
Immutable Deep State Updates in React with Ramda.js
Basic state updates in React are a breeze usingsetState
, 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 inRamda to handle deep state updates in a clean and functional way.
Let's start with a simple counter component.
importReactfrom'react';import{render}from'react-dom';classAppextendsReact.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><buttononClick={this.increase}>+</button><div>{this.state.count}</div><buttononClick={this.decrease}>-</button></div>)}}render(<App/>,document.getElementById('root'));
Here, we're using a function as the argument forsetState
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 thatcount
a little deeper into the state.
this.state={a:{name:'pointless structure',b:{stuff:'things',count:0}}}
This newstate
is incredibly contrived, but it'll help illustrate the point. Now, in order to update the count, we need to update propertya
, which in turn needs an updatedb
and that will contain our updatedcount
. The update function forincrease
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 withstate
. ThesetState
method doesn't merge recursively, so doing something likethis.setState((state) => ({a: {b:{ count: state.a.b.count + 1}}}))
would update the count, but the other properties ona
andb
would be lost. In order to prevent that, the returned object is created by spreading the existing properties ofstate.a
into a new object where we then replaceb
. Sinceb
also has properties that we want to keep, but don't want to change, we spreadstate.b
's props and replace justcount
, which is replaced with a new value based on the value instate.a.b.count
.
Of course, we need to do the same thing withdecrease
, so now the entire component looks like this:
importReactfrom'react';import{render}from'react-dom';classAppextendsReact.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><buttononClick={this.increase}>+</button><div>{this.state.a.b.count}</div><buttononClick={this.decrease}>-</button></div>)}}render(<App/>,document.getElementById('root'));
ThosesetState
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 thecount
property in our state, we will use ramda'slensPath
function and array that describes the path tocount
, like so:
import{lensPath}from'ramda'constcountLens=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
andover
. If we runview
, passing it our lens and the state object, we'll get back the value ofcount
.
import{lensPath,view}from'ramda'constcountLens=lensPath(['a','b','count'])// somewhere with access to the component's stateview(countLens,state)// 0
Admittedly,view
doesn't seem super useful since we could have just referenced the path tostate.a.b.count
or use ramda'spath
function. Let's see how we can do something useful with our lens. For that, we're going to use theset
function.
import{lensPath,view,set}from'ramda'constcountLens=lensPath(['a','b','count'])// somewhere with access to the component's stateconstnewValue=20set(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 ourstate
object in which the value ofstate.a.b.count
has been replaced with20
. 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 ourincrement
method in our component to look more like this:
increase(){this.setState((state)=>{constcurrentCount=view(countLens,state)returnset(countLens,currentCount+1,state)})}
We've usedview
with our lens to get the current value, and then calledset
to update the value based on the old value and return a brand new version of our entirestate
.
We can take this a step further. Theover
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'sinc
function to increment a number. So now we can make theincrease
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 passover
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 toover
returns a function that acceptsstate
. Well,setState
accepts a function that acceptsstate
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 thatover
function out of the component and give it a nice meaningful name:
// outside of the component:constincreaseCount=over(countLens,inc)// Back in the componentincrease(){this.setState(increaseCount)}
And of course, the same can be done to thedecrease
method usingdec
from ramda. This would make the whole setup for this component look like this:
importReactfrom'react';import{render}from'react-dom';import{inc,dec,lensPath,over}from'ramda'constcountLens=lensPath(['a','b','count'])constincreaseCount=over(countLens,inc)constdecreaseCount=over(countLens,dec)classAppextendsReact.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><buttononClick={this.increase}>+</button><div>{this.state.a.b.count}</div><buttononClick={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 thelensPath
. In fact, we could even use the lens along withview
to display our data inrender
and then we could rely on thatlensPath
to handleall 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 therender
method.
So here it is with that final adjustment, take it for a spin and see what you can do with it!
Top comments(3)

- LocationMandaluyong City, Philippines
- EducationUniversity of the Philippines Visayas Tacloban College
- WorkData Engineer at NextPay
- Joined
This is genius!

- Joined
Lenses, and the rest of the Ramda library, are pretty amazing for any kind of data manipulation

- LocationMandaluyong City, Philippines
- EducationUniversity of the Philippines Visayas Tacloban College
- WorkData Engineer at NextPay
- Joined
I use currying and other stuff in Ramda but I never tried lenses. Thanks!
For further actions, you may consider blocking this person and/orreporting abuse