Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Andy Van Slaars
Andy Van Slaars

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'));
Enter fullscreen modeExit fullscreen mode

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}}}
Enter fullscreen modeExit fullscreen mode

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}}}))}
Enter fullscreen modeExit fullscreen mode

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'));
Enter fullscreen modeExit fullscreen mode

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'])
Enter fullscreen modeExit fullscreen mode

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
Enter fullscreen modeExit fullscreen mode

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)
Enter fullscreen modeExit fullscreen mode

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}}}
Enter fullscreen modeExit fullscreen mode

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)})}
Enter fullscreen modeExit fullscreen mode

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))}
Enter fullscreen modeExit fullscreen mode

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))}
Enter fullscreen modeExit fullscreen mode

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))}
Enter fullscreen modeExit fullscreen mode

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)}
Enter fullscreen modeExit fullscreen mode

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'));
Enter fullscreen modeExit fullscreen mode

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!

Edit Ramda Lenses - React setState

Top comments(3)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
johnpaulada profile image
John Paul Ada
BA Psychology grad turned software engineer. Tech Evangelist. Data Engineer.
  • Location
    Mandaluyong City, Philippines
  • Education
    University of the Philippines Visayas Tacloban College
  • Work
    Data Engineer at NextPay
  • Joined

This is genius!

CollapseExpand
 
avanslaars profile image
Andy Van Slaars
Currently working as the Enterprise Architect for frontend technology. Fan of JavaScript, TypeScript, React and all things FE dev. Also an egghead.io Instructor.
  • Joined

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

CollapseExpand
 
johnpaulada profile image
John Paul Ada
BA Psychology grad turned software engineer. Tech Evangelist. Data Engineer.
  • Location
    Mandaluyong City, Philippines
  • Education
    University of the Philippines Visayas Tacloban College
  • Work
    Data Engineer at NextPay
  • Joined

I use currying and other stuff in Ramda but I never tried lenses. Thanks!

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Currently working as the Enterprise Architect for frontend technology. Fan of JavaScript, TypeScript, React and all things FE dev. Also an egghead.io Instructor.
  • Joined

Trending onDEV CommunityHot

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp