Here’s are the form elements we’ll build up to in part 4

In this article we’re going to step back and answer the question “will our forms API support most standard forms today?” And the answer to that question is, absolutely not! Radio buttons, checkboxes, multi-selects, etc. will not function properly. We have a good design in place but we need to validate that it can scale to support other input types. So that will be our goal of this article.

In part 1 of this series we investigated using React hooks to design a useForm React forms library. Part 1 covers the motivation and some overall design goals of the library.

In part 2 of this series we created a useInput hook, integrated it into our useForm hook, reviewed our solution’s state management, incorporated some additional tests, and reviewed testing strategies in more detail.

In part 3 we covered validation, asynchronous validation, and asynchronous form submits. It gave us the key elements of a React hooks based forms API.

Working example

Let’s check out a demo of everything that will be included in this part.

Sample form showcasing all of the form features we’ll develop from part 4

The full solution is found in my github. There is a lot of boilerplate in the demo, but I’d rather have the examples be very explicit at this point. Please note that tests don’t pass in CodeSandbox at this time due to a CodeSandbox design decision to not support loading devDependencies from package.json .

Designing the public API to support other input types

At this point, useForm only supports text-based inputs. I believe that the following are the most critical next steps:

Support radio button groups ( <input type=’radio’/> )

) Support checkboxes ( <input type=’checkbox’/> )

) Support multi-value selects ( <select multiple=’true’/> )

) Support custom inputs such as date pickers

Allow custom components to be class-based or function-based

Ensure that validations support stock HTML input types and custom components

Ensure that our library allows custom components to hook into onBlur and onFocus so that validation, visited state, and pristine state can be tracked

Form creators shouldn’t have to do anything special to get standard HTML inputs working, save for a couple of HTML specific implementation details when needed. But they shouldn’t have to worry about those implementation details. We’ll review implementation examples in this article.

Custom input design requirements

Custom inputs should follow a consistent API. useForm.addInput is used to configure a new input, whether it is a standard HTML input or custom input. useForm.addInput returns a bunch of input properties that should be supported by custom components. Here are the input properties useForm.addInput returns:

id : input id allows useForm to track form fields (required)

: input id allows to track form fields (required) value : input value (required)

: input value (required) onFocus : allows useInput to track if input has been visited

: allows to track if input has been visited onBlur : allows useForm to validate components on blur

: allows to validate components on blur onChange : allows useInput to track if the input’s value has been changed ( pristine ) (required)

All of these properties are standard React attributes and events. This keeps the API consistent with React’s model.

Figure 1 — custom components and input props

As shown in figure 1, to add a custom component:

Define your React custom component Implement the id and value properties returned from addInput in your custom component Call the onChange event that is returned from addInput in your custom component. onChange needs two values passed, the event, and the input value. useForm doesn’t know where the value property is in the event object for custom components, so being explicit about the value simplifies things Optionally implement the onFocus and onBlur functions that are returned from the addInput call in your custom component. This allows useInput to track visited and pristine state, and also allows form validations on blur

An example custom component that supports useForm

Let’s create a sample date picker component that uses the react-day-picker component. Our component supports all of the properties that useForm supports. For example, there are custom blur and focus functions. Notice that our component is a class component. Components that use useForm can be class or function based, there is no restriction.

Our custom component doesn’t have any explicit useForm dependencies. This keeps custom components nicely decoupled from the useForm library, other than calling the props.onChange , props.onBlur , and props.onFocus if they exist. These are the events used by the useForm library.

In render , <DayPicker {…this.props} /> expands all useForm properties passed into the component.

import React from "react";

import DayPicker from "react-day-picker";

import "react-day-picker/lib/style.css"; export class DatePicker extends React.Component {

handleOnDayClick = (value, modifiers, event) => {

console.log("custom change code...");

this.props.onChange && this.props.onChange(event, value);

};

handleOnBlur = event => {

console.log("custom blur code...");

this.props.onBlur && this.props.onBlur(event, this.props.value);

};

handleOnFocus = event => {

console.log("custom focus code...");

this.props.onFocus && this.props.onFocus(event);

}; render() {

const { value } = this.props; return (

<React.Fragment>

<DayPicker

onDayClick={this.handleOnDayClick}

{...this.props}

onBlur={this.handleOnBlur}

onFocus={this.handleOnFocus}

/>

{value && <p>You clicked {value.toLocaleDateString()}</p>}

</React.Fragment>

);

}

}

Using custom components with useForm

Using custom components is identical to using stock HTML components. In our sample form code below, we create a custom validation and use the api.addInput function to configure our custom component and wire up the validations.

We see that api is returned from the form’s useForm call. This API allows form creators to define inputs for their forms. The code then calls api.addInput to register the new preferredDate date field. When calling api.addInput , the new dateRangeValidator is passed, which allows useForm to use the validator and track validation errors with the rest of the standard useForm validation errors. This should make form validation easier to reason about and maintain.

Finally, we have <DatePicker {…preferredDate.getInputProps()} /> which is how we pass all of the api.addInput properties to our custom component. That’s it!

import { DatePicker } from "./date-picker"; ...

const { getFormProps, formValues, uiState, api, formValidity } = useForm(

"settingsForm",

{

firstName: "George",

lastName: "OfTheJungle",

email: "

custom: "custom",

agreeToTerms: false,

comments: "",

favouriteFlavour: "",

favouriteColours: ["red", "green"],

cookiesPerDay: null,

preferredDate: null

}

); function KitchenSink(props) {const { getFormProps, formValues, uiState,, formValidity } = useForm("settingsForm",firstName: "George",lastName: "OfTheJungle",email: " george@thejungle.com ",custom: "custom",agreeToTerms: false,comments: "",favouriteFlavour: "",favouriteColours: ["red", "green"],cookiesPerDay: null,); ... // Not a real reference example of how to validate dates :)

const dateRangeValidator = createValidator({

validateFn: date => {

const startDate = new Date(2018, 1, 1);

const endDate = new Date(2018, 12, 33);

return date && date >= startDate && date <= endDate;

},

error: "DATE_RANGE_ERROR"

}); const preferredDate = api.addInput({

id: "preferredDate",

value: formValues.preferredDate,

validators: [

{ ...required, when: ["onBlur", "onSubmit"] },

{ ...dateRangeValidator, when: ["onBlur", "onSubmit"] }

]

}); <fieldset className="field-group">

<legend>Select a date from 2018</legend>

<DatePicker {...preferredDate.getInputProps()} />

<div>

{JSON.stringify(preferredDate.uiState)} --{" "}

{JSON.stringify(formValidity.preferredDate)}

</div>

</fieldset>

Implementing stock HTML inputs

Checkboxes

Checkboxes expect value to be boolean. In the following code example, the form code adds an agreeToTerms input. The input uses the stock mustBeTrue validator, which expects a given input’s value to equal true. We set the validation to run onBlur and onSubmit . We expand the input properties returned from api.addInput with the code <input type=”checkbox” {…agreeToTerms.getCheckProps()} /> . mustBeTrue is a new stock validator to validate if checkboxes are checked.

Sample form code related to checkboxes:

///

const agreeToTerms = api.addInput({

id: "agreeToTerms",

value: formValues.agreeToTerms,

validators: [{ ...mustBeTrue, when: ["onBlur", "onSubmit"] }]

}); ///

<input type="checkbox" {...agreeToTerms.getCheckProps()} />

<label htmlFor={agreeToTerms.id}>

Checkbox that must be checked *

</label>

use-input.js code related to checkboxes:

const getCheckProps = inputProps => ({

...getSharedProps(),

checked: value,

...(typeof inputProps === "function" ? inputProps(props) : inputProps)

});

validators.js code related to checkboxes

validators.mustBeTrue = createValidator({

validateFn: value => value !== null && value !== undefined && value === true,

error: "MUST_BE_TRUE"

});

Radio buttons

Radio buttons need to track value and checked . This is unlike text inputs, which just track value, or checkboxes, which just track checked. To add a group of radio buttons, call api.addRadioGroup . There is no need to pass the radio options. For each <input type=”radio” /> , we call getInputProps and pass a unique radio button id . But each radio button gets the same name value. This is the standard HTML way to configure a radio button group.

Sample form code related to radio buttons:

/// const cookieOptions = [

{ id: "1", value: "1" },

{ id: "10", value: "10" },

{ id: "20", value: "20" }

];

const cookiesPerDay = api.addRadioGroup({

id: "cookiesPerDay",

value: formValues.cookiesPerDay,

validators: [{ ...required, when: ["onBlur", "onSubmit"] }]

}); /// <fieldset className="field-group">

<legend>

How many cookies per day *

<br />

{JSON.stringify(cookiesPerDay.uiState)} --{" "}

{JSON.stringify(formValidity.cookiesPerDay)}

</legend>

{cookieOptions.map(cookie => (

<React.Fragment key={cookie.id}>

<input

{...cookiesPerDay.getInputProps({

name: "cookiesPerDay",

id: `cookiesPerDay_${cookie.id}`,

value: cookie.value

})}

type="radio"

checked={cookie.value === formValues.cookiesPerDay}

/>

<label htmlFor={`cookiesPerDay_${cookie.id}`}>

{cookie.value}

</label>

</React.Fragment>

))}

</fieldset>

use-form.js code related to radio buttons

Whenever an individual radio button is blurred, it triggers the onRadioGroupBlur function. However, we don’t want to raise onBlur events when users are moving between radio buttons in the same group. So onRadioGroupBlur calls isBlurWithinRadioGroup to see if the blur goes to a radio button in the same group, or something else. This requires the form creator to ensure that each radio button in a group has the same name attribute value.

const isBlurWithinRadioGroup = (event, id) =>

event.relatedTarget && event.relatedTarget.getAttribute("name") === id; const onRadioGroupBlur = async ({ id, value, event }) => {

if (isBlurWithinRadioGroup(event, id)) return; if (validators[id]) {

if (isValidatorAlreadyRunning(id, value)) {

// No need to do anything at this point since validator is already running

return;

} setFormValidity({

...formValidity,

[id]: { ...formValidity[id], isValidating: true, value }

});

const validationResults = await runValidators({

field: id,

validators: validators[id],

eventType: "onBlur",

value

});

setFormValidity({ ...formValidity, [id]: validationResults });

}

};

Multi-selects

Multi-selects work with an array of string values. In the form code below, we initialize favouriteColours to [“red”, “green”] . We then initialize favouriteColours with api.addInput . As with radio button groups, we don’t pass the collection of values. Finally, we use the input with <select {…favouriteColours.getInputProps()} multiple={true}> , which is the same usage as a text input.

When we expand favouriteColours.getInputProps() , we get a value=[“red”, “green”] property that expands into the <select /> tag. This is how React expects multi-select values to be passed to select inputs.



"settingsForm",

{

firstName: "George",

lastName: "OfTheJungle",

email: "

custom: "custom",

agreeToTerms: false,

comments: "",

favouriteFlavour: "",

favouriteColours: ["red", "green"],

cookiesPerDay: null,

preferredDate: null

}

); const { getFormProps, formValues, uiState, api, formValidity } = useForm("settingsForm",firstName: "George",lastName: "OfTheJungle",email: " george@thejungle.com ",custom: "custom",agreeToTerms: false,comments: "",favouriteFlavour: "",cookiesPerDay: null,preferredDate: null); /// const favouriteColours = api.addInput({

id: "favouriteColours",

value: formValues.favouriteColours,

validators: [{ ...required, when: ["onBlur", "onSubmit"] }]

}); /// <div className="field-group">

<label htmlFor={favouriteColours.id}>Your favourite colours *</label>

{JSON.stringify(favouriteColours.uiState)} --{" "}

{JSON.stringify(formValidity.favouriteColours)}

<select {...favouriteColours.getInputProps()} multiple={true}>

<option value="red">Red</option>

<option value="green">Green</option>

<option value="blue">Blue</option>

<option value="yellow">Yellow</option>

</select>

</div>

use-input.js code related to multi-selects

getInputValue is where multi-select values are generated. For multi-selects ( type === “select-multiple” ), we iterate on the select’s options, find the options that are selected, and create an array of values based on the selected options. Select options are passed as a part of the React synthetic events for change, blur, and focus. So form creators don’t have to pass the select options to addInput .

const getInputValue = ({ type, checked, value, options }) => {

if (type === "checkbox") return checked;

if (type === "select-multiple")

return [...options]

.map(option => ({

value: option.value,

selected: option.selected

}))

.filter(option => option.selected)

.map(option => option.value);

if (type === "radio") {

if (checked) return value;

return undefined;

}

return value;

};

useForm code changes to support custom inputs

At this point there are no major changes needed to support custom inputs. The foundation laid out for stock HTML inputs scales to support custom inputs.

Bug fixes from part 3

There were a few bugs from part 3 that have been fixed:

Required validators work with null or undefined values now

All validations on a single input would run even if the first produced a validation error. My preference is to stop processing after the first validation error, in order to avoid unnecessary validation logic checks, network roundtrips, etc. The validation code is updated to reflect this

I renamed name to id almost everywhere since the library doesn’t really expand id’s into name attributes, and id is a more consistent term for the unique ID of things in general

to almost everywhere since the library doesn’t really expand id’s into attributes, and is a more consistent term for the unique ID of things in general useForm was not validating the onSubmit validators during form submit. It was previously validating the onBlur validators

was not validating the validators during form submit. It was previously validating the validators useForm was mutating validation state in an incorrect way with asynchronous validations. It now mutates validation state using a more consistent approach for validations running concurrently with the useRef React hook. I plan to write an article about this because it is a common issue that you can run into when using setState to manage shared state that might be updated asynchronously in no guaranteed order. In cases like these, useRef can help to resolve these issues, since useState guarantees a consistent copy of the state for the full function execution of a hook

Unit tests and code coverage — 💯% ?

Up until now I was just writing unit tests without reviewing code coverage. Our unit tests cover a lot of standard paths, but they don’t provide 100% coverage. But then again, we probably don’t need 100% coverage. We might not even need 90% coverage, but we should probably have more than 40 or 50% coverage.

Not bad but test coverage needs to improve over this

Martin Fowler’s article about test coverage has some thoughtful points to consider:

If you make a certain level of coverage a target, people will try to attain it. The trouble is that high coverage numbers are too easy to reach with low quality testing. I would say you are doing enough testing if the following is true: - You rarely get bugs that escape into production, and

- You are rarely hesitant to change some code for fear it will cause production bugs.

Most folks that I know have similar opinions to Mr. Fowler’s. I’ve been witness to production codebases with horribly low test coverage, and production codebases with high test coverage. For the purpose of the input library, my preference is to ensure that all code paths are tested across all levels, since it’s possible to use aspects of the API in isolation of other parts.

With Jest, simply add --coverage to automatically get code coverage reports. These reports can then be used in a continuous build / continuous integration environment, but for now tests will just run from the terminal window during development.

Scaling up the demo form

The demo form now demonstrates the following:

Checkboxes and “must be checked” validations on blur and submit

Radio button groups and validations on blur and submit

Select inputs and validations on blur and submit

Multi-select inputs and validations on blur and submit

Custom components (i.e. DatePicker) and validations on blur and submit

Check out the online demo and the code in the github repository to see everything working together.

Summary

In this post, we scaled up the form library to support most standard HTML inputs, as well as custom inputs. We fixed a few bugs that existed from part 3, talked about test coverage, and expanded upon our form sample.

In later parts we’ll cover other topics such as reducing boilerplate, adding validations from JSON schemas, promise cancellations, debounce, hook performance considerations, and other design optimizations.

At this point, the library is probably just about “good enough” to use in production 😃. Keep in mind the effort and thought that needs to go into making a forms library just “good enough” before you embark on the journey of making a forms library yourself!

Unless you have an extremely simple form that requires no validation logic, yes, you can cobble a form together with React, but it will be very limited. Once you need to get past simple forms, use a forms library. Otherwise you might not be making the best use of your time. Unless you’re doing it for your own learning purposes of course, which I always recommend learning by developing something that you’re interested in.

Let me know if you have any questions, feedback, or suggestions, or if you would like me to cover anything else within this series.