Using Lenses
Building complex React forms in a type-safe way
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:
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.firstNamelet 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:
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 object, 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 addressLens = forProp<Person>()(“address”)
let streetLens = forProp<Address>()(“street”);
let houseNumberLens = forProp<Address>()(“number”);let addressStreetLens = compose(addressLens, streetLens);
let addressNumberLens = compose(addressLens, houseNumberLens);<>
<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:
<>
{makeArray(hobbiesLens, person.hobbies.length).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 = (event: React.ChangeEvent<HTMLSelectElement>) => {
let id = event.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.
We could define a RadioButtonsField component, and it would be used in a very similar way to a 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 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