Łukasz Makuch

Łukasz Makuch

Consuming and updating the state object with Ramda lenses

When I was designing a new major release of Rosmaro, I wanted to make it as modular as possible. I totally understood why component composition in React was so important and that's why I strived to make Rosmaro models easy to compose as well. Another aspect I cared a lot when I was building the most modular Rosmaro so far was high cohesion. In order to avoid jumping from one place to another, I wanted to group things that change together and separate those that are not related to each other. So I had two, very ambitious goals: modularity (what means decoupling) and cohesion (what means smart decoupling).

Such a task was challenging, but it somehow felt like a breath of fresh air, because it was nothing like my day to day work. Instead of just making use of the knowledge and skills I already had, I needed to come up with an idea that was at this point unknown to me.

The Internet, and computers in general, are wonderful. I love to use them and I owe some of the most wonderful memories and feelings to them. But sometimes, when I need to simply think about a hard problem, I like to escape from them. My thoughts often tend to be visual and I find it somehow inspiring and pleasant to sketch things on paper or on a whiteboard, if I happen to have one around.

That day, when I wanted to find an answer to the question how to make Rosmaro models easy to compose and coherent in the same time, I decided to grab a pen and a couple of sheets of paper and go to the garden. Back then, I was living in the sunny city of Santa Cruz de la Sierra. Sitting under a palm tree, apparently inspired by the beauty of it, I had one of these aha moments! I got the idea to use a pair of symmetrical functions - one would map from form A to form B, and the other one would map from form B to form A. Unfortunately, I didn't know I just reinvented the concept of lenses. If I knew, it would save me some time. On the other hand, it was really nice to spend some time in that garden.

For a short period of time, Rosmaro shipped with its own implementation of lenses. It was like that until I discovered Ramda which is a wonderful functional JavaScript library that provides a tooling for working with lenses. In this post, I'll do my best to share with you all the reasons why I find (Ramda) lenses so appealing.

Regardless our framework of choice, it's quite common nowadays that there's some object representing the data-related state of the application. For example, it may look like this:

const state = {
  type: 'flat',
  area: 42,
  tenant: {
    name: 'Łukasz',
    pet: {
      type: 'Small animal',
      name: 'Kubuś',
    },
  },
};

If you're wondering whether I was recently looking for a flat, then you must know you're right. Ah, and when I was a kid, I had a guinea pig named Kubuś.

Let's get started! We're gonna start with something simple, that is a lens that will allow us to see the tenant property.

This is how we build the lens with Ramda:

const tenantLens = lensProp('tenant');

And this is how we "view" the state through this lens:

view(tenantLens, state)

It gives us the value of the tenant property:

{ name: 'Łukasz', pet: { type: 'Small animal', name: 'Kubuś' } }

The illustration visible below is my attempt of visualizing this process of zooming in with a lens: Zooming in

There's some big picture with many things, but when we look at it through this particular lens, all we can see is a small portion of it, magnified.

If the big picture changes and we look at it through the lens again, the magnified portion reflects this change: A change in the big picture

A change in the big picture reflected in the magnified portion

So far what we've seen is very much like a property selector. That's totally true that we could do something like state.tenant as well and get the same result. However, the power of lenses lies in their bidirectional nature. What it means is that they don't only reflect changes in the source, but also apply changes in the magnified portion to the source. Visually, it would look like this:

A change in the magnified portion A change in the magnified portion reflected in the source

In the code, it looks like this:

  set(
    tenantLens,
    {
      name: 'Łukasz',
      pet: {
        type: 'Small animal',
        name: 'Kubuś'
      }
    },
    state
  )

It gives us a new, updated object:

{
  type: 'flat',
  area: 42,
  tenant:     {
      name: 'Łukasz',
      pet: {
        type: 'Small animal',
        name: 'Kubuś'
      }
    }
  }

As we can see, it's possible to modify the zoomed in part and have this change applied to the source. The difference when compared to, let's say, mutable object-oriented programming, is that in the functional approach we aren't actually modifying any existing structure. Instead of that, a new, slightly different variant is created. However, the similarity to getter and setter methods clearly stands out and that's why lenses are often called functional getters and setters.

To illustrate how multiple lenses may be put together to create a telescope, we're gonna focus on the type of my pet. Even though it's technically possible, at least in the world of computers, to create a single lens that does it, we're gonna benefit more from building a telescope, that is a construct made of multiple lenses, because each of these lenses may be then reused in another telescope.

Let's start by creating a three lenses:

// Focuses on the `tenant` property.
const tenantLens = lensProp('tenant');
// Focuses on the `pet` property.
const petLens = lensProp('pet');
// Focuses on the `type` property.
const typeLens = lensProp('type');

Yay! Now we have everything what's needed to put together a telescope! 🔭

const tenantPetTypeTelescope = compose(tenantLens, petLens, typeLens);

We use the telescope in the same way we use an ordinary lens.

Both for reading:

view(tenantPetTypeTelescope, state)

that gives:

'Small animal'

and for writing:

set(tenantPetTypeTelescope, 'Guinea pig', state)

that updated only the very property the telescope is directed at:

{
  type: 'flat',
  area: 42,
  tenant: {
    name: 'Łukasz',
    pet: {
      type: 'Guinea pig',
      name: 'Kubuś'
    }
  }
}

Trying to discover what are lenses made of, we're gonna manually construct a lens that focuses on the area of the flat. Using the lensProp factory function, it's trivial to build it:

const areaLens = lensProp('area');

It's trivial, yet cryptic. Let's decompose it one level down:

const areaLens = lens(
  prop('area'),
  assoc('area'),
);

In the snippet visible above we're using the lens factory function. We pass it two functions:

  • the area property "getter" function, that simply reads the value of the area property
  • the area property "setter" function, that creates a new version of an object with the area property set to the value we pass to it

To be honest, I must admit, that when I first saw such an example it was still a bit enigmatic to me. It was only when I implemented both the getter and the setter functions myself that I really understood how to construct lenses. Going deeper, let's do the same with our areaLens example:

const areaLens = lens(
  wholeObject => wholeObject.area,
  (smallPiece, wholeObject) => ({ ...wholeObject, area: smallPiece })
);

The first function, that is the getter, takes one argument, that is the whole state object, and simply returns the area property. Very straightforward. The second function, which is the setter, is a bit more complex, as it gets two arguments: a small piece and the whole object. The whole object is meant to be the very same thing we passed to the getter function, which in our case is the whole state object (wholeObject). The small piece is supposed to be an updated version of the value returned by the getter (wholeObject.area). Only then the change to the wholeObject.area property may be applied to the wholeObject.

This is how this lens works, regardless the way it's constructed:

view(areaLens, state);
// output:
42
set(areaLens, 52, state);
// output:
{
  type: 'flat',
  area: 52,
  tenant: {
    name: 'Łukasz',
    pet: {
      type: 'Small animal',
      name: 'Kubuś'
    }
  }
}

Now, that we know lenses are not magic, but just two functions to map in and map out, let's try to do some even more interesting. So far we've been just zooming in and zooming out, but the cool thing is that we're not limited to just this. In the same way mirrors in a hall of mirrors don't need to reflect the reality, our lenses don't need to be symmetrical. They may cast a projection that's different than the fragment of the source image we're looking at. A deformed lens Please note that little bump on the surface of the lens. It's making the triangle shape in the source image being projected as a spherical one. And because our lenses are bidirectional, an update to the "distorted" projection will be reflected in the source.

For a more concrete example, let's try to enhance our areaLens in such a way it changes the unit from square meters to square centimeters.

const areaLens = lens(
  wholeObject => wholeObject.area * 10000,
  (smallPiece, wholeObject) => ({ ...wholeObject, area: smallPiece / 10000 })
);

Now we can treat the area like it were in square centimeters, both when reading and when writing, and in the whole state it will always be represented in square meters.

view(areaLens, state);
// output:
420000 // square centimeters
set(areaLens, 520000, state); // square centimeters
// output:
{
  type: 'flat',
  area: 52, // square meters
  tenant: {
    name: 'Łukasz',
    pet: {
      type: 'Small animal',
      name: 'Kubuś'
    }
  }
}

That's pretty neat! The little pieces of code that operate on the area are totally unaware that it's actually in a different unit and that there are some other fields as well. Thanks to the lens they are totally decoupled from the shape and size of the state object.

Another cool thing about Ramda lenses is how easy it is to use them with the pipe function. Because all Ramda functions are curried, what's returned by the set function when called with only two arguments (the lens and the value to set) is a function that takes the remaining argument, that is the object where the value is supposed to be set, and returns the updated version of this object.

Let's suppose that we want to update two fields in our global state object: the area (provided in square centimeters) and the type of the pet. Doing it using the spread operator would look like this:

{
  ...state,
  area: 450000 / 10000,
  tenant: {
    ...state.tenant,
    pet: {
      ...state.tenant.pet,
      type: 'Guinea pig'
    }
  }
}

This is heavily coupled to the structure of the state and quite error prone, because every time we update an object field, we need to remember to spread the rest of the properties, otherwise we're gonna lose some data. Also, all key names must be exactly the same. Usually it's not a problem, because within one project it's a good idea to keep the terminology consistent, but as soon as we want to share a piece of code that operates on the global state it becomes a challenge, because then the structure and key names must be consistent among all its clients, what makes it not so reusable.

With lenses, this is a lot cleaner, less coupled and reusable:

pipe(
  set(areaLens, 450000),
  set(tenantPetTypeTelescope, 'Guinea pig'),
)(state)

The next time we want to update the type of the pet, we can use exactly the same lens (or telescope):

set(tenantPetTypeTelescope, 'rodent', state)

We can use this telescope to read it as well:

view(tenantPetTypeTelescope, 'state') // 'rodent'

And if the name of the field changes, there's just one place that really requires a change - the telescope. For example, if the global state doesn't use type anymore, because it's been renamed to kind, the client code doesn't really need to know about it. This is how the updated telescope may look like:

const tenantPetTypeTelescope = lensPath(['tenant', 'pet', 'kind']);

Lenses turn out to be so useful, that they became first-class citizens in Rosmaro. For example, here's the code associated with a child node of the TodoMVC app implemented in Rosmaro:

// Child node (component)
export const ADD = ({ context: { content } }) => ({
  context: { content: '' },
  arrow: 'cleared',
  effect: [
    {
      type: 'DISPATCH',
      action: { type: 'TODO_ADD', content: trim(content) },
    },
  ],
});

As we can see, it treats the context, which is the Rosmaro's term for the global state, like it were an object with just one propety - content. It uses destructing to read it directly from the context: ({ context: { content } }) and when it needs to clear it, it just provides an object that consists of nothing but a single property: context: { content: '' }.

From the child node's perspective, the context is very simple and doesn't have any data it doesn't need to have. However, the whole context is a lot bigger:

{
  newTodoForm: {
    content: 'What I am typing'
  },
  list: {
    todos: [
      {
        id: 2,
        content: 'zc'
      },
      {
        id: 1,
        content: 'asdf'
      }
    ],
    lastId: 2
  }
}

It's only thanks to a proper lens associated with the parent node that the child node may stay focused solely on its own teeny tiny part of the context:

// Parent node (component)
export default opts => ({
  lens: () =>
    compose(
      sliceLens('newTodoForm'),
      initialValueLens({ content: '' })
    ),
  handler: transparentHandler,
});

Personally, I love lenses for their cohesion (keeping the code related to property access together) and the freedom they give (may be used to adapt an incompatible otherwise piece of code). They are one of those very intuitive constructs, that like state machines, may be reinvented and appear under various names (like adapters or user flows).

From the author of this blog

  • howlong.app - a timesheet built for freelancers, not against them!