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 TypeScript1 (1 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.
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 names2 (2 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!
Many mainstream React form libraries are written in plain-old JavaScript. Some of them are written in TypeScript, 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.
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.
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!
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:
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).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.
It is convenient to have a function that can automatically create a lens for a property. This is where forProp()
comes in:3 (3 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 TypeScript does not (yet) support partial type inference. We’re working around this limitation by having two function invocations. See TypeScript 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.
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.
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.
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 className="block pb-6"> <div className="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> </>
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.
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.
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.
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.
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.
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.