Carl Rippon

Building SPAs

Carl Rippon
BlogBooks / CoursesAbout
This site uses cookies. Click here to find out more

Building a React Form Component with TypeScript: Validation

June 19, 2018
reacttypescript

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:

Invalid form

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.

submit form

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.

Learn TypeScript

NEW!🎉LIMITED LAUNCH DISCOUNT

An interactive course for JavaScript developers who want to learn modern TypeScript

  • Learn to use TypeScript's amazing type system with your existing JavaScript skills to boost your productivity
  • Over 70 interactive tutorial style lessons
  • Quizzes in each chapter to reinforce knowledge
  • Covers beginner topics through to advanced
Learn TypeScript
Find out more