DEV Community

Cover image for Custom Form Validation using Hooks in React: Tips and Tricks with useForm and Superstruct
Francisco Mendes
Francisco Mendes

Posted on • Updated on

Custom Form Validation using Hooks in React: Tips and Tricks with useForm and Superstruct

Introduction

Forms are a common feature in most applications, and they typically involve maintaining a state, either locally or globally, that reflects any changes made to the form.

Today, I will show you how to create a custom hook to manage the state of forms. Instead of relying on libraries created by the community, we will use Superstruct to validate JSON objects.

Assumed knowledge

The following would be helpful to have:

  • Basic knowledge of React
  • Basic knowledge of Form Handling

Getting Started

Project Setup

Run the following command in a terminal:

yarn create vite app-form --template react-ts
cd app-form
Enter fullscreen mode Exit fullscreen mode

Now we can install the necessary dependencies:

yarn install superstruct
Enter fullscreen mode Exit fullscreen mode

Structure Definition

I suggest starting by creating a file for the useForm hook. Next, it would be beneficial to define some data types to standardize the hook's implementation.

The initial structure we will define is the hook's arguments, which will include three properties:

  • initialValues - corresponding to the initial state of the form, represented as an object with keys as the names of inputs.
  • validationSchema - this is the schema that will be used to validate the form state.
  • resetOnSubmit - if the form is submitted, the values of each key/input would be reset

Which would be similar to the following:

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

interface IArgs<T> {
  initialValues: T;
  validationSchema: Struct<T, unknown>;
  resetOnSubmit?: boolean;
}

// ...
Enter fullscreen mode Exit fullscreen mode

The next data type to be defined is errors, we will use the generic feature to infer each of the properties from the initial state, as follows:

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

interface IArgs<T> {
  initialValues: T;
  validationSchema: Struct<T, unknown>;
  resetOnSubmit?: boolean;
}

type IErrors<T> = Record<keyof T, string> | undefined;

// ...
Enter fullscreen mode Exit fullscreen mode

We will use a higher-order function to invoke the submission, the function that will be invoked has the following data type:

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

interface IArgs<T> {
  initialValues: T;
  validationSchema: Struct<T, unknown>;
  resetOnSubmit?: boolean;
}

type IErrors<T> = Record<keyof T, string> | undefined;
type IHandleSubmit<T> = (values: T) => void;

// ...
Enter fullscreen mode Exit fullscreen mode

The next structure to define are the props that we want to assign to inputs through the hook, this way we can handle some properties/attributes, as follows:

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

interface IArgs<T> {
  initialValues: T;
  validationSchema: Struct<T, unknown>;
  resetOnSubmit?: boolean;
}

type IErrors<T> = Record<keyof T, string> | undefined;
type IHandleSubmit<T> = (values: T) => void;

interface IGetInputOptions {
  includeError?: boolean;
  type?: "text" | "checkbox" | "textarea";
  placeholder?: string;
}

// ...
Enter fullscreen mode Exit fullscreen mode

Next, the function responsible for registering the input in the hook must return the input value, the onChange function, and the input type for it to be controlled.

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

interface IArgs<T> {
  initialValues: T;
  validationSchema: Struct<T, unknown>;
  resetOnSubmit?: boolean;
}

type IErrors<T> = Record<keyof T, string> | undefined;
type IHandleSubmit<T> = (values: T) => void;

interface IGetInputOptions {
  includeError?: boolean;
  type?: "text" | "checkbox" | "textarea";
  placeholder?: string;
}

interface IGetInputProps<T> extends Omit<IGetInputOptions, "includeError"> {
  onChange: (evt: ChangeEvent<unknown>) => void;
  error?: string;
  checked?: boolean;
  value?: number | string;
  name: keyof T;
}

// ...
Enter fullscreen mode Exit fullscreen mode

Hook Creation

After defining the data types, the next step is to create the functions for the hook. First, two states must be defined, one for managing the form state and another for managing form validation errors. Next, a function to reset the hook, including the previously defined states, must be created.

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

// ...

export const useForm = <T extends Record<string, any>>({
  initialValues,
  validationSchema,
  resetOnSubmit = true,
}: IArgs<T>) => {
  const [values, _setValues] = useState<T>(initialValues);
  const [errors, _setErrors] = useState<IErrors<T>>(undefined);

  const reset = useCallback(() => {
    _setErrors(undefined);
    _setValues(initialValues);
  }, []);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

We will now create an internal function named _onChange to handle the form's input, which takes the target/input instance into account. By using the Input instance (Input, Textarea, etc), the values will be handled differently.

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

// ...

export const useForm = <T extends Record<string, any>>({
  initialValues,
  validationSchema,
  resetOnSubmit = true,
}: IArgs<T>) => {
  const [values, _setValues] = useState<T>(initialValues);
  const [errors, _setErrors] = useState<IErrors<T>>(undefined);

  const reset = useCallback(() => {
    _setErrors(undefined);
    _setValues(initialValues);
  }, []);

  const _onChange = useCallback(({ target }: ChangeEvent<unknown>) => {
    let finalValue: string | boolean;
    let key: string;
    if (target instanceof HTMLInputElement) {
      const { type, checked, value, name } = target;
      finalValue = type === "checkbox" ? checked : value;
      key = name;
    } else if (target instanceof HTMLTextAreaElement) {
      const { value, name } = target;
      finalValue = value;
      key = name;
    }
    _setValues((currentValues) => ({ ...currentValues, [key]: finalValue }));
  }, []);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

The following step is to create a function that considers the form's state and the validation schema passed as arguments. If an error occurs and it is an instance of a Superstruct error, the error object should be flattened.

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

// ...

export const useForm = <T extends Record<string, any>>({
  initialValues,
  validationSchema,
  resetOnSubmit = true,
}: IArgs<T>) => {
  const [values, _setValues] = useState<T>(initialValues);
  const [errors, _setErrors] = useState<IErrors<T>>(undefined);

  const reset = useCallback(() => {
    _setErrors(undefined);
    _setValues(initialValues);
  }, []);

  const _onChange = useCallback(({ target }: ChangeEvent<unknown>) => {
    let finalValue: string | boolean;
    let key: string;
    if (target instanceof HTMLInputElement) {
      const { type, checked, value, name } = target;
      finalValue = type === "checkbox" ? checked : value;
      key = name;
    } else if (target instanceof HTMLTextAreaElement) {
      const { value, name } = target;
      finalValue = value;
      key = name;
    }
    _setValues((currentValues) => ({ ...currentValues, [key]: finalValue }));
  }, []);

  const _validate = useCallback(() => {
    try {
      assert(values, validationSchema);
    } catch (error) {
      if (error instanceof StructError) {
        const errorObj = error.failures().reduce(
          (acc, { key, message }) => ({
            ...acc,
            [key]: message,
          }),
          {}
        );
        _setErrors(errorObj as IErrors<T>);
        return errorObj;
      }
    }
    return {};
  }, [values, validationSchema]);

  // ...
};
Enter fullscreen mode Exit fullscreen mode

Then, we can proceed to create the function that will register an input in the hook, called getInputProps. This function will be responsible for assigning the input value, the internal _onChange function, and other properties related to the input.

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

// ...

export const useForm = <T extends Record<string, any>>({
  initialValues,
  validationSchema,
  resetOnSubmit = true,
}: IArgs<T>) => {
  const [values, _setValues] = useState<T>(initialValues);
  const [errors, _setErrors] = useState<IErrors<T>>(undefined);

  const reset = useCallback(() => {
    _setErrors(undefined);
    _setValues(initialValues);
  }, []);

  const _onChange = useCallback(({ target }: ChangeEvent<unknown>) => {
    let finalValue: string | boolean;
    let key: string;
    if (target instanceof HTMLInputElement) {
      const { type, checked, value, name } = target;
      finalValue = type === "checkbox" ? checked : value;
      key = name;
    } else if (target instanceof HTMLTextAreaElement) {
      const { value, name } = target;
      finalValue = value;
      key = name;
    }
    _setValues((currentValues) => ({ ...currentValues, [key]: finalValue }));
  }, []);

  const _validate = useCallback(() => {
    try {
      assert(values, validationSchema);
    } catch (error) {
      if (error instanceof StructError) {
        const errorObj = error.failures().reduce(
          (acc, { key, message }) => ({
            ...acc,
            [key]: message,
          }),
          {}
        );
        _setErrors(errorObj as IErrors<T>);
        return errorObj;
      }
    }
    return {};
  }, [values, validationSchema]);

  const getInputProps = useCallback(
    (
      name: keyof T,
      { includeError, type = "text", placeholder }: IGetInputOptions = {}
    ) => {
      const props: IGetInputProps<T> = { onChange: _onChange, name };
      if (includeError) props.error = errors?.[name];
      if (type === "checkbox") {
        props.checked = values?.[name];
        props.type = "checkbox";
      } else {
        props.value = values?.[name];
        props.type = type ?? "text";
        if (placeholder) props.placeholder = placeholder;
      }
      return props;
    },
    [errors, values]
  );

  // ...
};
Enter fullscreen mode Exit fullscreen mode

Finally, we will create a function to handle the form submission. It must prevent the default behavior, validate the form state, and if successful, reset it.

// @/src/hooks/useForm.ts
import { ChangeEvent, FormEvent, useCallback, useState } from "react";
import { Struct, StructError, assert } from "superstruct";

// ...

export const useForm = <T extends Record<string, any>>({
  initialValues,
  validationSchema,
  resetOnSubmit = true,
}: IArgs<T>) => {
  const [values, _setValues] = useState<T>(initialValues);
  const [errors, _setErrors] = useState<IErrors<T>>(undefined);

  const reset = useCallback(() => {
    _setErrors(undefined);
    _setValues(initialValues);
  }, []);

  const _onChange = useCallback(({ target }: ChangeEvent<unknown>) => {
    let finalValue: string | boolean;
    let key: string;
    if (target instanceof HTMLInputElement) {
      const { type, checked, value, name } = target;
      finalValue = type === "checkbox" ? checked : value;
      key = name;
    } else if (target instanceof HTMLTextAreaElement) {
      const { value, name } = target;
      finalValue = value;
      key = name;
    }
    _setValues((currentValues) => ({ ...currentValues, [key]: finalValue }));
  }, []);

  const _validate = useCallback(() => {
    try {
      assert(values, validationSchema);
    } catch (error) {
      if (error instanceof StructError) {
        const errorObj = error.failures().reduce(
          (acc, { key, message }) => ({
            ...acc,
            [key]: message,
          }),
          {}
        );
        _setErrors(errorObj as IErrors<T>);
        return errorObj;
      }
    }
    return {};
  }, [values, validationSchema]);

  const getInputProps = useCallback(
    (
      name: keyof T,
      { includeError, type = "text", placeholder }: IGetInputOptions = {}
    ) => {
      const props: IGetInputProps<T> = { onChange: _onChange, name };
      if (includeError) props.error = errors?.[name];
      if (type === "checkbox") {
        props.checked = values?.[name];
        props.type = "checkbox";
      } else {
        props.value = values?.[name];
        props.type = type ?? "text";
        if (placeholder) props.placeholder = placeholder;
      }
      return props;
    },
    [errors, values]
  );

  const submitForm = useCallback(
    (handleSubmit: IHandleSubmit<T>) => (evt: FormEvent) => {
      evt.preventDefault();
      const validationErrors = _validate();
      if (Object.keys(validationErrors).length === 0) {
        handleSubmit(values);
        if (resetOnSubmit) reset();
      }
    },
    [values, resetOnSubmit]
  );

  return {
    values,
    errors,
    getInputProps,
    submitForm,
    reset,
  };
};
Enter fullscreen mode Exit fullscreen mode

How to use

The hook's core functionality has been defined, and we now have everything necessary to handle forms. Next, let's create a small example of how to use it.

Ideally, we would create a validation schema and use it to infer a data type for use as a generic, providing autocomplete. Lastly, we would connect the hook, form, and corresponding inputs.

// @/src/App.tsx
import { useCallback } from "react";
import { object, nonempty, string, boolean, Infer } from "superstruct";

import { useForm } from "./hooks/useForm";

const validationSchema = object({
  name: nonempty(string()),
  message: nonempty(string()),
  isChecked: boolean(),
});

type IFormValues = Infer<typeof validationSchema>;

const App = () => {
  const { values, errors, getInputProps, submitForm } = useForm<IFormValues>({
    initialValues: {
      name: "",
      message: "",
      isChecked: false,
    },
    validationSchema,
  });

  const onSubmit = useCallback((formValues: IFormValues) => {
    alert(JSON.stringify(formValues));
  }, []);

  return (
    <section>
      <form onSubmit={submitForm(onSubmit)}>
        <input
          {...getInputProps("name", { placeholder: "Type your name..." })}
        />
        {errors?.name && <small>{errors.name}</small>}

        <textarea
          {...getInputProps("message", {
            type: "textarea",
            placeholder: "Leave a message...",
          })}
        />
        {errors?.message && (
          <>
            <small>{errors.message}</small>
            <br />
          </>
        )}

        <input {...getInputProps("isChecked", { type: "checkbox" })} />

        <br />
        <button type="submit">Send</button>
      </form>

      <pre>
        <code>{JSON.stringify(values, null, 2)}</code>
      </pre>
    </section>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Conclusion

I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.

Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.

Github Repo

Top comments (0)