Using Lenses

Building complex React forms in a type-safe way

Denis Defreyne
BCG Digital Ventures Engineering
15 min readNov 24, 2020

--

Lenses allow us to build complex forms quickly and with confidence.

While lenses themselves are simple by themselves (they’re bi-directional accessors for immutable objects), they provide significant benefits when applied for React forms in combination with TypeScript.

Forms are hard

At BCG Digital Ventures, I work on an internal tool where one particular page has a remarkably complex form. Its implementation has gone through several revisions to increase its stability and make it more maintainable, but even the most advanced version, powered with React, had problems.

The mainstream React form-handling libraries left us hanging: none of them can deal with highly-complex nested data structures in a type-safe way.

Type safety in particular is useful because it tightens the feedback loop: misspelled field names will be highlighted as errors in the IDE, and the IDE will provide reliable and useful autocompletion with integrated documentation.

I looked to the Haskell community for alternative approaches, and discovered the concept of lenses. As an experiment, I reimplemented the form using lenses, which turned out to be capable of handling highly-complex data in a type-safe way.

With lenses, we can build complex forms quickly and with confidence.

Here is a live demo of what we’ll build:

The demo application, which we’ll build in this article.

What is a lens?

To describe what a lens is, we’ll use an example to create a lens from scratch.

Imagine a Person type, and an instance of Person that represents Sherlock Holmes:

We can get Sherlock’s first name:

The game is afoot!

We could also write some code to update the first name, which we’ll do in a purely functional way, meaning that we won’t modify the object itself, but rather return a new object with the updated data:

Perhaps Sherlock wouldn’t like to be called Locky. We’ll never know.

We can create functions for getting and updating a person’s first name:

Let’s fix Sherlock’s name:

We can combine the getter and the setter into a single object:

Congratulations: firstNameLens is your first lens!

Lenses, more formally

The lens that we constructed above has the following type:

This lens is for two specific types:

  • The Person type is the top type: the type that contains the data that you want to extract (using get), or the data that you want to update (using set).
  • The string type is the focused type: the type of the extracted data.

With these two types in mind, we can construct a generic Lens type, with two type parameters (T for the top type, and F for the focused type):

The type definition makes it clear: a lens is the combination of a getter and a setter, for an immutable data structure.

Conveniently creating lenses with forProp()

It is convenient to have a function that can automatically create a lens for a property. This is where forProp() comes in:

A lens returned by forProp() behaves exactly the same as a manually-constructed one:

I’ll make a guess that John Watson wouldn’t like to be called Joe.

The forProp() function is type-safe, as it won’t accept non-existent properties:

We’ll not talk about the implementation, but you can check out its implementation in the demo sandbox.

Lenses for forms

A lens-powered form field needs three properties:

A form field needs the lens (for getting and setting the data for this field), but also top and setTop(), which are used for getting and setting the top-level object.

Note the similarity between top and setTop() and what the React useState hook returns — we’ll come back to this later.

This minimalist text field’s implementation is as follows:

This React component is a controlled component, so the wrapped <input> component is given both a value prop and an onChange prop.

Minimal form example

We’ll create a form for a new person. First, we’ll need our lenses:

We’ll also need a way to create a blank person object:

The skeleton of our form will look like this:

When the form is created, the person variable is initialized to a new person.

At the end of the form, we show the pretty-printed representation of the person, so that you can see that it indeed is updating the person properly.

Let’s add the fields for the first name and last name:

We can reduce the amount of boilerplate by creating an object f that contains top and setTop(), so that we can pass it to the text fields succinctly:

With this approach, you can build forms with nested objects in a terse and type-safe way.

Prettier form example

The text field we’ve created so far is nothing but a wrapper for an input element. We can build a more full-fledged text field by adding a label and some styling (I am partial to utility-first CSS):

Once we replace our BareTextField usage in the form with TextField (now with label), we get a nicer form:

Handling nested data by composing lenses

We are able to build forms for simple objects now, but not for nested objects. Let’s fix that.

Image a Person type with an address inside it:

Let’s take Sherlock as an example person:

We can get Sherlock’s street easily:

Updating Sherlock’s street is more cumbersome without lenses:

If Address were a standalone object, we’d be able to update it succinctly with a lens:

We can, however, create a lens for a person’s address, and for an address’ street, and compose them, so that we get a lens for a person’s street:

The addressStreetLens lens “drills down” into the person type. It behaves like any other lens:

This works for forms too, like any other lens:

With compose(), you can build type-safe forms for deeply-nested data structures.

The implementation of compose() looks complex, but it is worth looking at:

Pay attention to the type signature: given a Lens<T, S> and a Lens<S, F>, return a Lens<T, F>. Once the type signature is in place, the implementation follows: the type system guides the implementation, and the type system will virtually guarantee correctness.

Handling lists

We already saw how to create a lens for a property of an object, using forProp():

While handling properties of an object is done with forProp(), handling elements of a list can done with forIndex():

In practice, though, forIndex() is not nearly as useful as forProp(). The forProp() function works well because we know the properties of an object ahead of time. For lists, on the other hand, the size is not known ahead of time, as lists can grow and shrink during execution.

To get a better understanding of how lists and lenses interact, let’s imagine a Person type with a list of hobbies:

We can create a lens for the list of hobbies:

A lens that focuses on a string[] is not directly useful, though. We’ll want to create one text field for each hobby, and a TextField component needs a lens that focuses on a string, not on a string[].

To access individual list elements, we need lenses for each list element. Rather than a single lens that focuses on a list of hobbies, we need a collection of lenses, each focusing on a single hobby:

Note the distinction in the type signature: hobbiesLens is one lens, while hobbyLenses is an array of lenses. While hobbiesLens focuses on a string array, hobbyLenses each focus on a single string.

To transform hobbiesLens into hobbyLenses, we need to know the number of elements in the list, so that we can generate the appropriate number of lenses. This is where makeArray() is useful:

We’ve left the implementation of makeArray out, but it has this signature, in case you want to give implementing it a shot yourself (or check out the code in the demo):

Once we have our list of lenses, we can create a TextField for each of these lenses:

We now have a form where we can edit existing list elements, but not add or remove any yet.

Adding and removing list elements

While the approach above works for modifying existing elements in a list, we need the ability to add new elements to a list and remove elements from a list.

To add an element to a list, we can use push(), whose type signature is (top: T, lens: Lens<T, F[]>, elem: F) => T:

In a React form, you could use push() as follows:

The implementation of push() relies on over(), which applies a transformation over the value that the lens focuses on:

The over() function is sometimes called transform() or map(). I prefer the latter personally, because it really feels like mapping. Here’s an example which transform’s Sherlock’s name into UPPERCASE!!!, for no particular reason:

Once we have over(), we can implement push():

Now that we have push(), we can add new elements to a list. We are still lacking the ability to remove elements from a list, though. For this, there’s removeAt(), which removes an element at a specified index:

“Sleuthing” is a dated word. Good riddance, I say.

In a React form, you’d use removeAt() like this:

The removeAt() function can also implemented using over(), with some appropriate use of slice() to retain only the elements that are not to be removed:

With all this in place, we have a type-safe way of managing lists.

Improving on single-select dropdowns

HTML provides the <select> element to create a dropdown list. Each element of this dropdown list, an <option>, has a value which identifies the selected option:

If we wanted to give a Person a favorite color, we could add a string property:

We could then use lenses to create a SingleSelectField, similar to our TextField. An implementation for this approach wouldn’t be too challenging.

There’s a limitation with this approach: the single-select dropdown values have to be strings. While this is common when building HTML single-select fields, we can do better, and treat dropdown values as full objects instead.

Imagine a Color type, and a Person whose favoriteColor property is a reference to a Color type:

It’s beneficial to have a reference to Color rather than a string, because it allows us to encode additional information besides a value and a display label. For example, if a person has selected their favorite color, we can show a welcome message in their selected favorite color:

The options for our single-select dropdown will be objects, and so we’ll need to define that list of objects as an array:

I can’t decide whether my favorite color is Stardust (so warm and powerful!) or Mint (so fresh and relaxing!).

We’ll also need a lens for a person’s favorite color:

We’ll create a DropdownField React component later on, but for now, let’s look at how we’d use it:

The DropdownField has the usual properties top and setTop (passed in using {…f}), as well as lens, but there are two additional properties: the values property is the list of all option objects, and the renderValue property is a function that returns the display label:

We’ll create a DropdownField React component, which will wrap a <select> HTML element. We will require value objects to have an id (hence the F extends { id: string }):

This required id will be used as the value property of an <option> later on.

We’ll use the lens to get the currently-selected option:

We’ll need a callback function, for use later on, that is triggered when the <select> option changes. We can get the id of the currently-selected option, but we’ll have to loop through all options to find the one that matches:

To render the <select> element, we loop over all values and generate <option> elements for each of them, and we set up the value and onChange properties of the <select> element:

Note that the DropdownField implementation has some assumptions built-in. There is always the empty option, and the value type is nullable (F | null, rather than just F). Additionally, each option object must have an id property. These assumptions might not hold in all situations.

Let’s take a moment to think about UX. A <select> element is a kind of single-select form field. Another kind of single-select form field is a set of radio buttons (<input type=”radio”>). In HTML, a select dropdown and a set of radio buttons is implemented quite differently, even though they serve a near-identical purpose.

We could define a RadioButtonsField component, and it would be used in a very similar way to a DropdownField:

The type signature of a RadioButtonsField is nearly identical to that of a DropdownField. The only difference is the type of the renderValue prop: for DropdownField, renderValue has to return a string, while for DropdownField, it can return a JSX.Element as well.

It’s important to highlight what we’ve achieved here. Dropdowns and radio buttons have wildly different implementations in HTML, even though their purpose is nearly identical: picking one item from a list. The nearly-identical signature of RadioButtonsField and DropdownField make the parallels visible, and make it trivial to swap out one type of single-select for the other.

Future work

While lenses are an effective way of building forms, there are three areas where more research and development is needed to make lenses stand out as an approach to building React forms:

  • Validation: Lenses can be used in combination with common validation techniques, such as HTML’s built-in validation functionality and schema-based validation. However, these techniques don’t integrate neatly with lenses, and further research is needed to come with an approach where validation feels like a first-class concern.
  • Form helpers: While lenses themselves are simple, using them effectively for forms requires implementations for all types of form fields, from simple text fields to different types of multi-select lists. The demo contains the minimal implementation of some types of form fields, which ideally would grow and be properly packaged as an open-source package.
  • Performance: The performance of this particular implementation of lenses, and implementations of the form fields, has not been a cause for concern so far. Still, it is likely that situations will arise where the performance of lenses is just not adequate. More work needs to be done to ensure that lenses are an appropriate solution, even for unusually large and complex forms.

Closing thoughts

To me, lenses have proven their worth, and will have a prominent place in my toolbox for building forms. No other approach to building forms gives me the same development speed or gives me as much confidence.

With lenses, I can be confident that the forms I build work, with only a minimum of testing. The real-time IDE feedback and autocompletion means I can work faster, without compromising quality.

Interested in working with us at BCGDV? Want to find out more? See our current vacancies.

Find us on Twitter @DV_Engineering

--

--