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:

  • An 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.

form component structure

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 sufficient 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 the 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 confusion. I'll explain this point later on.

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 the validation and submission part of the form. The process is simple. When the user click submits, we'll run through every validator 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 objects 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 returns 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 users 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.

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. An 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.

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.

All together now!

We've arrived at 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!

Thank you for reading to this point. I hope this is useful. Let me know your thoughts and comments :)

Form in action

Edit minimal-form-js

This post is also available on DEV.