Building a React Form Component with TypeScript: Validation
This is the fourth post in a series of blog posts where we are building our own super simple form component in React and TypeScript. In the last post we leveraged the context api to encapsulate the managing of form values. In this post we’ll tackle validation - a must for any form.
We haven’t validated any of the field values yet but we obviously need to do so. We are going to validate when a field loses focus as well as during form submission.
Refactoring some code
We are going to put a validation function in Form
. This means that Form
needs to know about all the fields within it. So, we are going to create a fields
prop and refactor some code …
Here’s the new fields
prop which is an object literal containing IFieldProps
:
export interface IFields { [key: string]: IFieldProps;}interface IFormProps {
/* The http path that the form will be posted to */
action: string;
/* The props for all the fields on the form */ fields: IFields;
/* A prop which allows content to be injected */
render: () => React.ReactNode;
}
This means we need to refactor ContactUsForm
:
import * as React from "react";
import { Form, IFields } from "./Form";
import { Field } from "./Field";
export const ContactUsForm: React.SFC = () => {
const fields: IFields = { name: { id: "name", label: "Name" }, email: { id: "email", label: "Email" }, reason: { id: "reason", label: "Reason", editor: "dropdown", options: ["", "Marketing", "Support", "Feedback", "Jobs"] }, notes: { id: "notes", label: "Notes", editor: "multilinetextbox" } }; return (
<Form
action="http://localhost:4351/api/contactus"
fields={fields} render={() => (
<React.Fragment>
<div className="alert alert-info" role="alert">
Enter the information below and we'll get back to you as soon as we
can.
</div>
<Field {...fields.name} /> <Field {...fields.email} /> <Field {...fields.reason} /> <Field {...fields.notes} /> </React.Fragment>
)}
/>
);
};
Validation state
We already have state in Form
for validation errors:
export interface IErrors { /* The validation error messages for each field (key is the field name */ [key: string]: string;}
export interface IFormState {
/* The field values */
values: IValues;
/* The field validation error messages */ errors: IErrors;
/* Whether the form has been successfully submitted */
submitSuccess?: boolean;
}
Validator functions
Now that we have the right structure for validation, let’s get on with creating some functions that are going to do the validation …
Let’s create some validator functions in Form
:
/**
* Validates whether a field has a value
* @param {IValues} values - All the field values in the form
* @param {string} fieldName - The field to validate
* @returns {string} - The error message
*/
export const required = (values: IValues, fieldName: string): string =>
values[fieldName] === undefined ||
values[fieldName] === null ||
values[fieldName] === ""
? "This must be populated"
: "";
/**
* Validates whether a field is a valid email
* @param {IValues} values - All the field values in the form
* @param {string} fieldName - The field to validate
* @returns {string} - The error message
*/
export const isEmail = (values: IValues, fieldName: string): string =>
values[fieldName] &&
values[fieldName].search(
/^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/
)
? "This must be in a valid email format"
: "";
/**
* Validates whether a field is within a certain amount of characters
* @param {IValues} values - All the field values in the form
* @param {string} fieldName - The field to validate
* @param {number} length - The maximum number of characters
* @returns {string} - The error message
*/
export const maxLength = (
values: IValues,
fieldName: string,
length: number
): string =>
values[fieldName] && values[fieldName].length > length
? `This can not exceed ${length} characters`
: "";
Specifying validation on a field
With our validator functions in place let’s introduce a prop to Field
to allow consumers to add validation:
export interface IValidation {
rule: (values: IValues, fieldName: string, args: any) => string;
args?: any;
}
export interface IFieldProps {
...
/* The field validator function and argument */
validation?: IValidation;
}
The above allows consumers to add any validator function that satisfies the ( IValues, string, any) => string
signature.
Driving validation from Form
Moving back to Form
, let’s create that validate()
function we talked about earlier. This function invokes the validator function if there is one and adds the validation error to the errors
state.
/**
* Executes the validation rule for the field and updates the form errors
* @param {string} fieldName - The field to validate
* @returns {string} - The error message
*/
private validate = (fieldName: string): string => {
let newError: string = "";
if (
this.props.fields[fieldName] &&
this.props.fields[fieldName].validation
) {
newError = this.props.fields[fieldName].validation!.rule(
this.state.values,
fieldName,
this.props.fields[fieldName].validation!.args
);
}
this.state.errors[fieldName] = newError;
this.setState({
errors: { ...this.state.errors, [fieldName]: newError }
});
return newError;
};
We can now expose validate()
in IFormContext
:
export interface IFormContext extends IFormState {
/* Function that allows values in the values state to be set */
setValues: (values: IValues) => void;
/* Function that validates a field */
validate: (fieldName: string) => void;}
So, let’s add this to the instance of IFormContext
in Form.render()
:
const context: IFormContext = {
...this.state,
setValues: this.setValues,
validate: this.validate};
Calling Form.validate from Field
Moving back to Field
, let’s call validate()
from IFormContext
when the editor loses focus:
{editor!.toLowerCase() === "textbox" && (
<input
...
onBlur={() => context.validate(id)}
...
/>
)}
{editor!.toLowerCase() === "multilinetextbox" && (
<textarea
...
onBlur={() => context.validate(id)}
...
/>
)}
{editor!.toLowerCase() === "dropdown" && (
<select
...
onBlur={() => context.validate(id)}
...
</select>
)}
Showing the validation errors
So, when the editor loses focus, validation should now occur and errors
in Form
state should be set. The validation errors aren’t rendering though, so, let’s implement that in Field
…
Let’s display the validation error under the label and editor:
/** * Gets the validation error for the field * @param {IErrors} errors - All the errors from the form * @returns {string[]} - The validation error */const getError = (errors: IErrors): string => (errors ? errors[id] : "");
...
return (
<FormContext.Consumer>
{(context: IFormContext) => (
<div className="form-group">
...
{getError(context.errors) && ( <div style={{ color: "red", fontSize: "80%" }}> <p>{getError(context.errors)}</p> </div> )} </div>
)}
</FormContext.Consumer>
);
Let’s also highlight the editor if the field is invalid:
/** * Gets the inline styles for editor * @param {IErrors} errors - All the errors from the form * @returns {any} - The style object */const getEditorStyle = (errors: IErrors): any => getError(errors) ? { borderColor: "red" } : {};
...
return (
<FormContext.Consumer>
{(context: IFormContext) => (
<div className="form-group">
{editor!.toLowerCase() === "textbox" && (
<input
...
style={getEditorStyle(context.errors)} ...
/>
)}
{editor!.toLowerCase() === "multilinetextbox" && (
<textarea
...
style={getEditorStyle(context.errors)} ...
/>
)}
{editor!.toLowerCase() === "dropdown" && (
<select
...
style={getEditorStyle(context.errors)} ...
/>
)}
...
</div>
)}
</FormContext.Consumer>
);
Adding validation to ContactUsForm
Okay, now that we’ve implemented all these validation bits in Form
and Field
, let’s make use of this in ContactUsForm
…
We want the person’s name and the reason for contact to be required fields. The person’s email should be a valid email. The notes should also be limited to 1000 characters.
The code changes are below:
import * as React from "react";
import { Form, IFields, required, isEmail, maxLength } from "./Form";import { Field } from "./Field";
export const ContactUsForm: React.SFC = () => {
const fields: IFields = {
name: {
id: "name",
label: "Name",
validation: { rule: required } },
email: {
id: "email",
label: "Email",
validation: { rule: isEmail } },
reason: {
id: "reason",
label: "Reason",
editor: "dropdown",
options: ["", "Marketing", "Support", "Feedback", "Jobs"],
validation: { rule: required } },
notes: {
id: "notes",
label: "Notes",
editor: "multilinetextbox",
validation: { rule: maxLength, args: 1000 } }
};
return (
...
);
};
Here’s a screen shot of the form in an invalid state:
Performing validation on form submission
There’s 1 remaining validation bit to implement. This is validating all the fields during the submission process. To do this we need to implement validateForm()
that was created in the first post …
/**
* Executes the validation rules for all the fields on the form and sets the error state
* @returns {boolean} - Returns true if the form is valid
*/
private validateForm(): boolean {
const errors: IErrors = {};
Object.keys(this.props.fields).map((fieldName: string) => {
errors[fieldName] = this.validate(fieldName);
});
this.setState({ errors });
return !this.haveErrors(errors);
}
So, if we hit the submit button without filling in the form, all the validation rules will be invoked, the errors will be displayed and the form won’t be submitted.
Wrapping up
Our components are looking fairly sophisticated with the inclusion of basic validation capabilities. Validation in practice can be a lot more complex though … for example, having multiple rules for a field (e.g. having the email required as well as a valid format). Validation rules also sometimes need to be asynchronous (e.g. the rule invokes a web api) … Our components can be expanded to deal with these complexities but this is beyond the scope of what I plan to cover in these blog posts.
The final bit we need to implement in our components is submitting our form to our web api. We’ll cover that in our next post.
If you to learn more about using TypeScript with React, you may find my course useful: