DEV Community

Andy Van Slaars
Andy Van Slaars

Posted on • Edited on • Originally published at vanslaars.io

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

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

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

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

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

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

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

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

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

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

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

Top comments (3)

Collapse
 
johnpaulada profile image
John Paul Ada

This is genius!

Collapse
 
avanslaars profile image
Andy Van Slaars

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

Collapse
 
johnpaulada profile image
John Paul Ada

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