Form validation with Yup under React and Material-UI

garryxiao

Garry Xiao

Posted on May 2, 2020

Form validation with Yup under React and Material-UI

In a real project, facing the form validation soon begin on frontend coding. After several rounds of refactoring, I completed it with 4 points in my project:

  1. Totally TypeScript
  2. Speed up development with depository support
  3. Custom hook
  4. Minimum refactoring for components

Chose Yup for validation schema definition, it is simple and easy to understand:
https://github.com/jquense/yup

npm install -S yup
npm install -D @types/yup
Enter fullscreen mode Exit fullscreen mode

React custom hook is a common function, with parameters for input and return necessary tool methods. useFormValidator as below is a custom hook and only rely on packages "react" and "yup", no relationship with the Material-UI framework:

import React from "react"
import * as Yup from 'yup'

/**
 * Form validator state field
 */
interface FormValidatorStateField {
    /**
     * Is error state
     */
    error: boolean

    /**
     * state text
     */
    text: string
}

/**
 * Form validator state fields
 */
interface FormValidatorStateFields {
    [key: string]: FormValidatorStateField
}

/**
 * Form validatior
 * @param schemas Initial validation schemas
 * @param milliseconds Merge change update interval
 */
export const useFormValidator = (schemas: Yup.ObjectSchema<object>, milliseconds: number = 200) => {
    // useState init
    const defaultState: FormValidatorStateFields = {}
    const [state, updateState] = React.useState<FormValidatorStateFields>(defaultState)

    // Change timeout seed
    let changeSeed = 0

    // Change value handler
    const commitChange = (field: string, value: any) => {
        // Validate the field, then before catch, if catch before then, both will be triggered
        Yup.reach(schemas, field).validate(value).then(result => {
            commitResult(field, result)
        }).catch(result => {
            commitResult(field, result)
        })
    }

    // Commit state result
    const commitResult = (field: string, result: any) => {
        let currentItem = state[field]
        if(result instanceof Yup.ValidationError) {
            // Error
            if(currentItem) {
                // First to avoid same result redraw
                if(currentItem.error && currentItem.text == result.message)
                    return

                // Update state
                currentItem.error = true
                currentItem.text = result.message
            } else {
                // New item
                const newItem: FormValidatorStateField = {
                    error: true,
                    text: result.message
                }
                state[field] = newItem
            }
        } else {
            // Success and no result, just continue
            if(currentItem == null)
                return

            // Delete current state result
            delete state[field]
        }

        // Update state, for object update, need a clone
        const newState = {...state}
        updateState(newState)
    }

    // Clear timeout seed
    const clearSeed = () => {
        if(changeSeed > 0)
            clearTimeout(changeSeed)
    }

    // Delay change
    const delayChange = (field: string, value: any) => {
        clearSeed()

        changeSeed = setTimeout(() => {
            commitChange(field, value)
        }, milliseconds)
    }

    // Merge into the life cycle
    React.useEffect(() => {
        return () => {
            // clearTimeout before dispose the view
            clearSeed()
        }
    }, [])

    // Return methods for manipulation
    return {
        /**
         * Input or Textarea blur handler
         * @param event Focus event
         */
        blurHandler: (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
            const { name, value } = event.currentTarget
            delayChange(name, value)
        },

        /**
         * Input or Textarea change handler
         * @param event Change event
         */
        changeHandler: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
            const { name, value } = event.currentTarget
            delayChange(name, value)
        },

        /**
         * Commit change
         */
        commitChange: commitChange,

        /**
         * State error or not
         * @param field Field name
         */
        errors: (field: string) => {
            return state[field]?.error
        },

        /**
         * State text
         * @param field Field name
         */
        texts: (field: string) => {
            return state[field]?.text
        },

        /**
         * Validate form data
         * @param data form data, Object.fromEntries(new FormData(form))
         */
        validate: async (data: any) => {
            try
            {
                clearSeed()
                return await schemas.validate(data, { strict: true, abortEarly: false, stripUnknown: false })
            }
            catch(e)
            {
                // Reset
                const newState: FormValidatorStateFields = {}

                // Iterate the error items
                if(e instanceof Yup.ValidationError) {
                    for(let error of e.inner) {
                        // Only show the first error of the field
                        if(newState[error.path] == null) {
                            // New item
                            const newItem: FormValidatorStateField = {
                                error: true,
                                text: error.message
                            }

                            newState[error.path] = newItem
                        }
                    }
                }

                // Update state
                updateState(newState)
            }

            return null
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When use it in Materal-UI pages, for example a login page:

// Login component
function Login() {

    // Form validator
    const { blurHandler, changeHandler, errors, texts, validate } = useFormValidator(validationSchemas)

    // Login action
    async function doLogin(event: React.FormEvent<HTMLFormElement>) {
        // Prevent default action
        event.preventDefault()

        // Form JSON data
        let data = await validate(Object.fromEntries(new FormData(event.currentTarget)))
        if(data == null)
          return

        // Local data format

        // Parase as model
        const model = data as LoginModel
   }

    return (
        <Container component="main" maxWidth="xs">
        <CssBaseline />
        <img src={window.location.origin + '/logo.jpg'} alt="Logo" className={classes.logo}/>
        <div className={classes.paper}>
          <Avatar className={classes.avatar}>
            <LockOutlined />
          </Avatar>
          <Typography component="h1" variant="h5">
            Sign in
          </Typography>
          <form className={classes.form} onSubmit={doLogin} noValidate>
            <TextField
              variant="outlined"
              margin="normal"
              required
              fullWidth
              id="id"
              label="Id or Email"
              name="id"
              error={errors('id')}
              helperText={texts('id')}
              onChange={changeHandler}
              onBlur={blurHandler}
              autoComplete="email"
              autoFocus
            />
            <TextField
              variant="outlined"
              margin="normal"
              type="password"
              required
              fullWidth
              name="password"
              error={errors('password')}
              helperText={texts('password')}
              onChange={changeHandler}
              onBlur={blurHandler}
              label="Password"
              id="password"
              autoComplete="current-password"
            />
            <FormControlLabel
              control={<Checkbox name="save" value="true" color="primary" />}
              label="Remember me"
            />
            <Button
              type="submit"
              fullWidth
              variant="contained"
              color="primary"
              className={classes.submit}
            >
              Sign In
            </Button>
          </form>
        </div>
      </Container>
    )
Enter fullscreen mode Exit fullscreen mode

First declear the validation schemas, initialize 'useFormValidator' and accept the returned methods for binding:

              error={errors('password')}
              helperText={texts('password')}
              onChange={changeHandler}
              onBlur={blurHandler}
Enter fullscreen mode Exit fullscreen mode

Through bindings only to current components to indicate any validation errors occur. No refactoring or extending for current components. That's the key feature of the task I enjoyed.

💖 💪 🙅 🚩
garryxiao
Garry Xiao

Posted on May 2, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related