Using Lenses to build complex React forms in a type-safe way

November 2020
revised July 2021
Denis Defreyne
32min read

Forms in React are not straightforward to build. For trivial forms, plain-old React is good, but once the forms get more complex, you’ll likely find yourself searching for a good form-building library for React.

In this article, I propose an alternative approach to building forms, one based on lenses, tightly integrated with Type­Script (The approach outlined in this article will work with JavaScript as well, but you’ll not get the type-safety benefits.) to tighten the feedback loop, reducing the change of mistakes and speeding up development.

Table of contents

  1. Table of contents
  2. The trouble with forms
  3. What is a lens?
  4. Lenses for forms
  5. Handling nested data by composing lenses
  6. Handling lists
  7. Improving on single-select dropdowns
  8. Future work
  9. Closing thoughts

The trouble with forms

During my time at BCG Digital Ventures, I worked on an internal tool where one particular page has a remarkably complex form: multiple dozens of text fields, radio buttons, checkboxes, and dropdowns, and in addition to all that, it had editable lists inside editable lists, with even more form fields inside.

The implementation of this form had gone through several revisions to increase its robustness and make it more maintainable. The penultimate version was built with Formik, which removed a ton of complexity and provided a good out-of-the-box experience.

However, even the version built with Formik had issues. Like other mainstream React form-handling libraries, Formik has trouble dealing with highly-complex nested data structures. In particular, I found that spelling mistakes in field names (I make typign mistakes all the time, and have trouble detecting them — perhaps I’m mildly dyslexic?) create subtle bugs that are hard to detect, and require extensive automated or manual testing to uncover.

We can improve on this by making good use of the type system!

Tightening the feedback loop using type safety

Many mainstream React form libraries are written in plain-old JavaScript. Some of them are written in Type­Script, but are lax with types, and provide little type safety. Type safety is useful because tightens the feedback loop: misspelled field names will be highlighted as errors in the IDE in real time, and the IDE will provide reliable and useful autocompletion with integrated documentation.

The functional programming community came up with the concept of lenses. Lenses, as you’ll see in more detail further down, are in essence bi-directional accessors for immutable objects. The immutability fits in nicely with React, and the type safety they provide tightens the development feedback loop.

I reimplemented the remarkably complex form using lenses, and was delighted with the outcome: lenses are capable of handling highly-complex data in a type-safe way. Lenses helped eliminated bugs and prevent regressions, and significantly sped up development. Lenses allow building complex forms quickly and with confidence.

Demo app

Below, you will find a live demo of something you can build with lenses. You can find the full implementation in the matching demo Git repository.

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:

type Person = {
  firstName: string;
  lastName: string;
};

let sherlock: Person = {
  firstName: "Sherlock",
  lastName: "Holmes"
};

We can get Sherlock’s first name:

sherlock.firstName
  // -> "Sherlock"

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:

let locky = { ...sherlock, firstName: "Locky" }
  // -> {
  //      firstName: "Locky",
  //      lastName: "Holmes"
  //    }

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 getFirstName =
  (person: Person) => person.firstName

let setFirstName =
  (person: Person, firstName: string) =>
    ({ ...person, firstName: firstName })

Let’s fix Sherlock’s name:

getFirstName(locky)
  // -> "Locky"

setFirstName(locky, "Sherlock")
  // -> {
  //      firstName: "Sherlock",
  //      lastName: "Holmes"
  //    }

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

let firstNameLens = {
  get: (person: Person) =>
    person.firstName

  set: (person: Person, firstName: string) =>
    ({ ...person, firstName: firstName })
}

firstNameLens.get(locky)
  // -> "Locky"

firstNameLens.set(locky, "Sherlock")
  // -> {
  //      firstName: "Sherlock",
  //      lastName: "Holmes"
  //    }

Congratulations: firstNameLens is your first lens!

Lenses, more formally

The lens that we constructed above has the following type:

type PersonFirstNameLens = {
  // Given a person,
  // get a string (the first name)
  get: (person: Person) => string;

  // Given a person and a string
  // (the first name),
  // return a new person
  set: (person: Person, firstName: string) => Person;
};

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):

type Lens<T, F> = {
  get: (t: T) => F;
  set: (t: T, f: F) => T;
};

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: (The double function invocation is necessary! When creating a lens using forProp, we need to specify the top type. If forProp only had a single function invocation, we’d have to specify both the top type and the focus type, because Type­Script does not (yet) support partial type inference. We’re working around this limitation by having two function invocations. See Type­Script issue #26242 for details.)

let firstNameLens =
  forProp<Person>()("firstName");

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

let john: Person = {
  firstName: "John",
  lastName: "Watson"
};

firstNameLens.get(john);
  // -> "John"
firstNameLens.set(john, "Joe");
  // -> { firstName: "Joe", lastName: "Watson" }

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:

// Type error!
// Person has no property middleName
let middleNameLens =
  forProp<Person>()("middleName");

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:

interface BareTextFieldProps<T> {
  lens: Lens<T, string>;
  top: T;
  setTop: (t: T) => void;
}

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:

export let BareTextField = <T extends any>({
  lens,
  top,
  setTop
}: BareTextFieldProps<T>) => {
  // Read value through lens
  let value = lens.get(top);

  // Replace top with new instance
  // updated through lens
  let setValue = (newValue: string) => {
    setTop(lens.set(top, newValue));
  };

  return (
    <input
      type="text"
      value={value}
      onChange={e => setValue(e.target.value)}
    />
  );
};

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:

let firstNameLens =
  forProp<Person>()("firstName");

let lastNameLens =
  forProp<Person>()("lastName");

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

let newPerson = (): Person => ({
  firstName: "",
  lastName: ""
});

The skeleton of our form will look like this:

let PersonForm = () => {
  let [person, setPerson] = useState(newPerson);

  return (
    <>
      {/* to do: add form fields here */}
      <pre>{JSON.stringify(person, null, 2)}</pre>
    </>
  );
};

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:

<>
  <BareTextField
    top={person}
    setTop={setPerson}
    lens={firstNameLens}
  />

  <BareTextField
    top={person}
    setTop={setPerson}
    lens={lastNameLens}
  />

  <pre>{JSON.stringify(person, null, 2)}</pre>
</>

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:

let PersonForm = () => {
  let [person, setPerson] = useState(newPerson);
  let f = { top: person, setTop: setPerson };

  return (
    <>
      <BareTextField {...f} lens={firstNameLens} />
      <BareTextField {...f} lens={lastNameLens} />
      <pre>{JSON.stringify(person, null, 2)}</pre>
    </>
  );
};

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):

interface TextFieldProps<T> extends BareTextFieldProps<T> {
  label: string;
}

let TextField = <T extends any>({
  lens,
  top,
  setTop,
  label
}: TextFieldProps<T>) => (
  <label class="block pb-6">
    <div style="font-bold">{label}</div>
    <BareTextField
      lens={lens}
      top={top}
      setTop={setTop}
    />
  </label>
);

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

<>
  <TextField
    {...f}
    lens={firstNameLens}
    label="First name"
  />
  <TextField
    {...f}
    lens={lastNameLens}
    label="Last name"
  />
  <pre>{JSON.stringify(person, null, 2)}</pre>
</>

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:

type Address = {
  street: string;
  number: string;
};

type Person = {
  firstName: string;
  lastName: string;
  address: Address;
};

Let’s take Sherlock as an example person:

let sherlock: Person = {
  firstName: "Sherlock",
  lastName: "Holmes",
  address: {
    street: "Butcher Street",
    number: "221b"
  }
}

We can get Sherlock’s street easily:

sherlock.address.street

Updating Sherlock’s street is more cumbersome without lenses:

let sherlock2 = {
  ...sherlock,
  address: {
    ...sherlock.address,
    street: "Baker Street"
  }
}

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

let sherlocksHome: Address = {
street: "Butcher Street",
number: "221b"
}

let streetLens =
  forProp<Address>()("street");

streetLens.set(sherlocksHome, "Baker Street");
  // -> { street: "Baker Street", number: "221b" }

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:

let addressLens =
  forProp<Person>()("address");

let streetLens =
  forProp<Address>()("street");

let addressStreetLens =
  compose(addressLens, streetLens);

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

let sherlock: Person = {
  firstName: "Sherlock",
  lastName: "Holmes",
  address: {
    street: "Butcher Street",
    number: "221b"
  }
}

addressStreetLens.set(sherlock, "Baker Street");
  // -> {
  //      firstName: "Sherlock",
  //      lastName: "Holmes",
  //      address: {
  //        street: "Baker Street",
  //        number: "221b"
  //      }
  //    }

This works for forms too, like any other lens. Let’s create the lenses we need first:

let addressLens =
  forProp<Person>()("address")
let streetLens =
  forProp<Address>()("street");
let houseNumberLens =
  forProp<Address>()("number");
let addressStreetLens =
  compose(addressLens, streetLens);
let addressNumberLens =
  compose(addressLens, houseNumberLens);

Now we can use them in a form:

<>
  <TextField
    {...f}
    lens={firstNameLens}
    label="First name" />

  <TextField
    {...f}
    lens={lastNameLens}
    label="Last name" />

  <TextField
    {...f}
    lens={addressStreetLens}
    label="Street" />

  <TextField
    {...f}
    lens={addressNumberLens}
    label="House number" />

  <pre>{JSON.stringify(person, null, 2)}</pre>
</>

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:

let compose = <T, S, F>(
  ts: Lens<T, S>,
  sf: Lens<S, F>
): Lens<T, F> => ({
  get: t => sf.get(ts.get(t)),
  set: (t, f) => ts.set(t, sf.set(ts.get(t), f))
});

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():

let sherlock: Person = {
  firstName: "Sherlock",
  lastName: "Holmes"
};

firstNameLens.get(sherlock);
  // -> "Sherlock"

firstNameLens.set(sherlock, "Locky");
  // -> {
  //      firstName: "Locky",
  //      lastName: "Holmes"
  //    }

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

let hobbies = ["programming", "arguing"];

let first = forIndex<string>(0);

first.get(hobbies);
  // -> "programming"

first.set(hobbies, "coding");
  // -> ["coding", "arguing"]

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:

type Person = {
  firstName: string;
  lastName: string;
  hobbies: string[];
};

We can create a lens for the list of hobbies:

let hobbiesLens: Lens<Person, string[]> =
  forProp<Person>()("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:

let hobbyLenses: Lens<Person, string>[] =
  ??? // Implemented further down

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:

let hobbyLenses: Lens<Person, string>[] =
  makeArray(hobbiesLens, person.hobbies.length);

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):

(
  lens: Lens<T, F[]>,
  length: number
): Lens<T, F>[]

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

let hobbiesLens = forProp<Person>()("hobbies");
let PersonForm = () => {
  let [person, setPerson] = useState(newPerson);
  let f = { top: person, setTop: setPerson };

  let hobbyLenses =
    makeArray(hobbiesLens, person.hobbies.length);

  return (
    <>
      {hobbyLenses.map(hobbyLens =>
        <TextField
          {...f}
          lens={hobbyLens}
          label="Hobby"
        />
      )}

      <pre>{JSON.stringify(person, null, 2)}</pre>
    </>
  );
};

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:

let hobbiesLens =
  forProp<Person>()("hobbies");

let sherlock: Person = {
  firstName: "Sherlock",
  lastName: "Holmes",
  hobbies: ["deducing"]
}

push(sherlock, hobbiesLens, "sleuthing")
  // -> {
  //      firstName: "Sherlock",
  //      lastName: "Holmes",
  //      hobbies: ["deducing", "sleuthing"]
  //    }

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

<button
  onClick={() =>
    setPerson(push(sherlock, hobbiesLens, ""))
  }
>New hobby</button>

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

let over = <T, F>(
  top: T,
  lens: Lens<T, F>,
  fn: (f: F) => F
): T =>
  lens.set(top, fn(lens.get(top)));

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:

let firstNameLens =
  forProp<Person>()("firstName");

let sherlock: Person = {
  firstName: "Sherlock",
  lastName: "Holmes"
}

over(
  person,
  firstNameLens,
  (a) => a.toUpperCase()
);
// -> {
//      firstName: "SHERLOCK",
//      lastName: "Holmes"
//    }

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

let push = <T, F>(
  top: T,
  lens: Lens<T, F[]>,
  elem: F
): T =>
  over(top, lens, elems => [...elems, elem]);

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:

let hobbiesLens =
  forProp<Person>()("hobbies");

let sherlock: Person = {
  firstName: "Sherlock",
  lastName: "Holmes",
  hobbies: [
    "deducing",
    "sleuthing",
    "investigating",
  ]
}

removeAt(sherlock, hobbiesLens, 1)
  // -> {
  //      firstName: "Sherlock",
  //      lastName: "Holmes",
  //      hobbies: ["deducing", "investigating"]
  //    }

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

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

let hobbyLenses =
  makeArray(hobbiesLens, person.hobbies.length);

<>
  {hobbyLenses.map((hobbyLens, index) =>
    <>
      <TextField
        {...f}
        lens={hobbyLens}
        label="Hobby"
      />

      <button
        onClick={() =>
          setPerson(
            removeAt(sherlock, hobbiesLens, index)
          )
        }
      >Remove hobby</button>
    </>
  )}
</>

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:

let removeAt =
  <T, F>(
    top: T,
    lens: Lens<T, F[]>,
    idx: number
  ): T =>
    over(
      top,
      lens,
      elems => [
        ...elems.slice(0, idx),
        ...elems.slice(idx + 1)
      ]
    );

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:

<select>
  <option></option>
  <option value="mari">Marina</option>
  <option value="star">Stardust</option>
  <option value="ruby">Ruby</option>
  <option value="sapp">Sapphire</option>
  <option value="elec">Electric</option>
  <option value="mint">Mint</option>
  <option value="slat">Slate</option>
</select>

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

type Person = {
  firstName: string;
  lastName: string;
  favoriteColor: string | null;
};

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:

type Color = {
  id: string;
  name: string;
  hex: string;
};

type Person = {
  firstName: string;
  lastName: string;
  favoriteColor: Color | null;
};

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:

<div style={{ color: person.favoriteColor?.hex || "#000" }}>
  Hello, {person.firstName}!
</div>

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

let allColors: Array<Color> = [
  { id: "mari", hex: "#0089a8", name: "Marina" },
  { id: "star", hex: "#e74132", name: "Stardust" },
  { id: "ruby", hex: "#bc1a50", name: "Ruby" },
  { id: "sapp", hex: "#45439d", name: "Sapphire" },
  { id: "elec", hex: "#c2d62e", name: "Electric" },
  { id: "mint", hex: "#29bc75", name: "Mint" },
  { id: "slat", hex: "#546173", name: "Slate" },
];

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:

let favoriteColorLens: Lens<Person, Color> =
  forProp<Person>()("favoriteColor");

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

<DropdownField
  {...f}
  lens={favoriteColorLens}
  values={allColors}
  renderValue={m => m.name}
/>

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:

interface DropdownFieldProps<T, F> {
  lens: Lens<T, F | null>;

  top: T;
  setTop: (top: T) => void;

  values: Array<F>;
  renderValue: (f: F) => string;
}

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 }):

let DropdownField = <T, F extends { id: string }>(
props: DropdownFieldProps<T, F>
) => {

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:

  let value: F | null = props.lens.get(props.top);

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:

  let onChange =
    (ev: React.ChangeEvent<HTMLSelectElement>) => {
      let id = ev.target.value;

      let value =
        props.values.find(v => v.id === id)
        || null;

      props.setTop(
        props.lens.set(props.top, value)
      );
    };

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:

  return (
    <select
      value={value?.id}
      onChange={onChange}
    >
      <option value=""></option>
      {props.values.map(value => (
        <option value={value.id} key={value.id}>
          {props.renderValue(value)}
        </option>
      ))}
    </select>
  );

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: selecting one item from a collection.

We could define a RadioButtonsField component, whose usage resembles DropdownField:

<RadioButtonsField
  {...f}
  lens={favoriteColorLens}
  values={allColors}
  renderValue={m => m.name}
/>

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 also return a JSX.Element.

This approach makes it trivial to swap a DropdownField for a RadioButtonsField. In plain-old HTML or React, this would not be the case, because dropdowns and radio buttons have wildly different implementations. Ideally, components with a similar purpose have a (near-)identical type signatures. This way, we can delay UX decisions around which kind of single-select field to use (dropdown or radio buttons), as we’ll be able to change one for the other trivially.

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:

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.