Hackernoon logoHow To Handle Form and Validation with React? by@si-le

How To Handle Form and Validation with React?

Author profile picture

@si-leSi Le

Self-taught developer trying to do something else than just coding

Overview

Handling form is an extremely common usecase for web applications. In this post, let's explore a way to handle form inputs and validations using React without using a third-party library.

Requirements

We will cover the most popular functionalities that will apply for most usecases:
  • onSubmit
    callback for components using the form.
  • Validation for single input (front-end only).
  • Validation
    onSubmit
    , not
    onBlur
    .
  • Reset form.

How does it work?

We will create a Form context that will hold all the states and define all state interactions for all form inputs.
When an input is mounted, certain information passed in these inputs will be used to supply to the Form context.
When an input within the form context changes, it will submit its new value to form context. Form context receives the values and changes its state to new value and pass it down to the input (controlled input).
When the form is submitted, it will run through all the validations that was registered when the input mounted and set the errors for specific fields. Those will then be passed down to the right input and rendered accordingly.
The figure below summarizes the responsibilities for each type of component.

Implementation

Form State

This form state needs to be able to hold 3 pieces of information:
  • Form data - for user's input data.
  • Validations - for field specific validations.
  • Errors - for field specific errors.
I think this object should be suffice to work with.
const FORM_STATE = {
  data: {},
  validators: {},
  errors: {},
};
We will also make a convention that every input must have a unique name prop to identify itself. It is similar to how a regular HTML5 form
<input>
has name property.
It is important for the name to be unique because we will use them as keys in our state structure.
For example, an input with name
first_name
will be store in
FORM_STATE
as follow:
{
  data: {
    first_name: "John",
  },
  validators: {
    first_name: [fn()],
  },
  errors: {
    first_name: ["error message"],
  }
}

Form Context

To inject form state and methods to every components that want to subscribe to it, we will use context provider pattern. You can read more about context here.
In my understanding, context is a wrapper that inject props into any child component that subscribe to it through a consumer. There is a convenient way to subscribe to context by using
useContext
hook.
We will also create an HOC to encapsulate the context subscription logic in one place so that our input can be as purely UI as possible. In other words, inputs are presentational components that will only listen to prop changes. Form context is the container that will hold most of the logic.

Form Methods

Let's go through step by step how form context should behave.

Registration

When an input is mounted, it should register itself with form context. On registration, we simply will copy validators from that input to store inside form context.
When an input is unmounted, we should clear its validations, errors, and any data associated with that input. Here's the registration function.
  const registerInput = ({ name, validators }) => {
    setFormState(state => {
      return {
        ...state,
        validators: {
          ...state.validators,
          [name]: validators || []
        },
        // clear any errors
        errors: {
          ...state.errors,
          [name]: []
        }
      };
    });
    // returning unregister method
    return () => {
      setFormState(state => {
        // copy state to avoid mutating it
        const { data, errors, validators: currentValidators } = { ...state };

        // clear field data, validations and errors
        delete data[name];
        delete errors[name];
        delete currentValidators[name];

        return {
          data,
          errors,
          validators: currentValidators
        };
      });
    };
  };
The registration function will return a function to unregister this input. It will only remove that input with the same name.

Input data control

Controlled inputs require us to use an onChange function to set a value somewhere, either in a redux store or in a state. In our form, we will hijack it and set a value in our form context before passing up the value. That way, the input itself is more flexible, although, it does come with some confusions.
When an input changes, we simply set its value to our form context's
data
object. Here is the implementation.
  const setFieldValue = (name, value) => {
    setFormState(state => {
      return {
        ...state,
        data: {
          ...state.data,
          [name]: value
        },
        errors: {
          ...state.errors,
          [name]: []
        }
      };
    });
  };
In addition to setting the input's data, we also clear its own errors under the assumption that if there was an error when the form submitted, user must have seen the inline errors. Now they're correcting the value for that field.

Submission and validation

Next, we have validation and submission part of the form. The process is simple. When user click submit, we'll run through every validators in form context, call the validator with 2 arguments:
  1. The value of the input.
  2. The data object as a whole.
Why do we pass data object into validators? Technically, we don't have to, but I think it's nice to have the validator aware of the whole form data. That way, we can perform cross input validation if we want.
If all validators return empty messages. It's good. The form will call
onSubmit
callback.
If ANY validator return an error message, we'll set the errors hash with that input's name and error messages. The form is now invalid and
onSubmit
callback will not be called.
Let's take a look at the implementation.
  const validate = () => {
    const { validators } = formState;

    // always reset form errors
    // in case there was form errors from backend
    setFormState(state => ({
      ...state,
      errors: {}
    }));
    if (isEmpty(validators)) {
      return true;
    }
    const formErrors = Object.entries(validators).reduce(
      (errors, [name, validators]) => {
        const { data } = formState;
        const messages = validators.reduce((result, validator) => {
          const value = data[name];
          const err = validator(value, data);
          return [...result, ...err];
        }, []);

        if (messages.length > 0) {
          errors[name] = messages;
        }

        return errors;
      },
      {}
    );
    if (isEmpty(formErrors)) {
      return true;
    }
    setFormState(state => ({
      ...state,
      errors: formErrors
    }));
    return false;
  };
It's important to note that we're running through all validators and collect all errors before returning. Otherwise, when user have errors in 2 fields, they'll have to fix one, submit, get another error, and fix the second field.
That's it! We've got our form context ready. Here's the full code below.
import React, { useState } from "react";
import PropTypes from "prop-types";
import { isEmpty } from "lodash";

const propTypes = {
  initialValues: PropTypes.object,
  onSubmit: PropTypes.func.isRequired,
  onReset: PropTypes.func
};

const initState = props => {
  return {
    data: {
      ...props.initialValues
    },
    validators: {},
    errors: {}
  };
};

let FormContext;
const { Provider } = (FormContext = React.createContext());

const Form = props => {
  const [formState, setFormState] = useState(initState(props));

  const onSubmit = e => {
    e.preventDefault();

    if (validate()) {
      props.onSubmit(formState.data);
    }
  };

  const validate = () => {
    const { validators } = formState;

    // always reset form errors
    // in case there was form errors from backend
    setFormState(state => ({
      ...state,
      errors: {}
    }));

    if (isEmpty(validators)) {
      return true;
    }

    const formErrors = Object.entries(validators).reduce(
      (errors, [name, validators]) => {
        const { data } = formState;
        const messages = validators.reduce((result, validator) => {
          const value = data[name];
          const err = validator(value, data);
          return [...result, ...err];
        }, []);

        if (messages.length > 0) {
          errors[name] = messages;
        }

        return errors;
      },
      {}
    );

    if (isEmpty(formErrors)) {
      return true;
    }

    setFormState(state => ({
      ...state,
      errors: formErrors
    }));

    return false;
  };

  const onReset = e => {
    e.preventDefault();
    setFormState(initState(props));
    if (props.onReset) {
      props.onReset();
    }
  };

  const setFieldValue = (name, value) => {
    setFormState(state => {
      return {
        ...state,
        data: {
          ...state.data,
          [name]: value
        },
        errors: {
          ...state.errors,
          [name]: []
        }
      };
    });
  };

  const registerInput = ({ name, validators }) => {
    setFormState(state => {
      return {
        ...state,
        validators: {
          ...state.validators,
          [name]: validators || []
        },
        // clear any errors
        errors: {
          ...state.errors,
          [name]: []
        }
      };
    });

    // returning unregister method
    return () => {
      setFormState(state => {
        // copy state to avoid mutating it
        const { data, errors, validators: currentValidators } = { ...state };

        // clear field data, validations and errors
        delete data[name];
        delete errors[name];
        delete currentValidators[name];

        return {
          data,
          errors,
          validators: currentValidators
        };
      });
    };
  };

  const providerValue = {
    errors: formState.errors,
    data: formState.data,
    setFieldValue,
    registerInput
  };

  return (
    <Provider value={providerValue}>
      <form
        onSubmit={onSubmit}
        onReset={onReset}
        className={props.className}
        id={props.id}
      >
        {props.children}
      </form>
    </Provider>
  );
};

Form.propTypes = propTypes;

export default Form;
export { FormContext };

Form HOC

Now that we have form context, we will make an wrapper to inject those context methods into any input component. This is optional because you can always use a context hook. Though, I think it's convenient.
The HOC will handle input registration, filtering errors and input value, and set data in form context.
First, let's subscribe to form context with
useContext
hook.
const { 
  errors, 
  data, 
  setFieldValue, 
  registerInput 
} = useContext(
  FormContext
);
After that, we'll register to Form context with
useEffect
.
useEffect(
  () =>
    registerInput({
      name: props.name,
      validators: props.validators
    }),
  []
);
We also return the unregistration function, so when this input is unmounted, it will not affect the form data or its validations anymore.
Then, we need to get the right input value and error for the wrapped input.
const inputValue = data[props.name];
const inputErrors = errors[props.name] || [];
Error will always be an array of error messages. Empty error array means there are no errors.
Lastly, we need to hijack the onChange callback so we can store this wrapped input's value to form context.
const onChange = val => {
  setFieldValue(props.name, val);
  if (props.onChange) {
    props.onChange(val);
  }
};
Here is the entire implementation.
import React, { useContext, useEffect } from "react";
import PropTypes from "prop-types";

import { FormContext } from "./index";

const propTypes = {
  name: PropTypes.string.isRequired,
  validators: PropTypes.arrayOf(PropTypes.func)
};

const withForm = InputComponent => {
  const WrappedWithForm = props => {
    const { errors, data, setFieldValue, registerInput } = useContext(
      FormContext
    );

    useEffect(
      () =>
        registerInput({
          name: props.name,
          validators: props.validators
        }),
      []
    );

    const onChange = val => {
      setFieldValue(props.name, val);
      if (props.onChange) {
        props.onChange(val);
      }
    };
    const inputValue = data[props.name];
    const inputErrors = errors[props.name] || [];

    return (
      <InputComponent
        {...props}
        errors={inputErrors}
        value={inputValue}
        onChange={onChange}
      />
    );
  };

  WrappedWithForm.propTypes = propTypes;
  return WrappedWithForm;
};

export default withForm;

Text Input

Finally, something usable. Let's make a text input using our form. Our input will have the following:
- A label
- The input itself
- Any errors
- onChange callback
It will receive in errors and value from form context. Based on form context, it will render accordingly. This is quite simple to implement.
Here's the implementation.
import React from "react";
import PropTypes from "prop-types";
import { isEmpty } from "lodash";
import classNames from "classnames";

import withForm from "./with_form";

import "./styles.css";

const propTypes = {
  placeholder: PropTypes.string,
  name: PropTypes.string,
  value: PropTypes.string,
  label: PropTypes.string,
  type: PropTypes.string,
  errors: PropTypes.arrayOf(PropTypes.string)
};

const defaultProps = {
  value: "",
  type: "text"
};

const TextInput = props => {
  const hasError = !isEmpty(props.errors);

  const renderErrors = () => {
    if (!hasError) {
      return null;
    }

    const errors = props.errors.map((errMsg, i) => (
      <li key={`${props.name}-error-${i}`} className="error">
        {errMsg}
      </li>
    ));

    return <ul className="error-messages">{errors}</ul>;
  };

  const onChange = e => {
    const val = e.target.value;
    props.onChange(val);
  };

  const klass = classNames("form-group", {
    "has-error": hasError
  });

  return (
    <div className={klass}>
      <label>{props.label}</label>
      <input
        name={props.name}
        type={props.type}
        className="form-control"
        placeholder={props.placeholder}
        onChange={onChange}
        value={props.value}
      />
      {renderErrors()}
    </div>
  );
};

TextInput.propTypes = propTypes;
TextInput.defaultProps = defaultProps;

const FormTextInput = withForm(TextInput);

export { TextInput };
export default FormTextInput;

All together now!

We've arrive to the end! Yay! Let's put together a sign up form as an example.
<Form onSubmit={data => console.log(data)}>
  <TextInput
    name="first_name"
    validators={[requiredValidator]}
    placeholder="John"
    label="First Name"
  />
  <TextInput
    name="last_name"
    validators={[requiredValidator]}
    placeholder="Smith"
    label="Last Name"
  />
  // .... truncate
  <button className="submit-btn" type="submit">
    Register!
  </button>
  <button className="submit-btn danger" type="reset">
    Reset
  </button>
</Form>
We'll simply log out the data for now. We'll also put in a few validators to make sure that it works. Let's take a look at a sample validator.
const requiredValidator = val => {
  if (!val) {
    return ["This field is required"];
  }

  return [];
};
Try clicking submit and reset to see how it works! (CodeSandbox below)
Thank you for reading to this point. I hope this is useful. Let me know your thoughts and comments :)

Form in action

Author profile picture

@si-leSi Le

Read my stories

Self-taught developer trying to do something else than just coding

Tags

The Noonification banner

Subscribe to get your daily round-up of top tech stories!