loading...

Form validation with Yup under React and Material-UI

garryxiao profile image Garry Xiao ・3 min read

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

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
        }
    }
}

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>
    )

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

              error={errors('password')}
              helperText={texts('password')}
              onChange={changeHandler}
              onBlur={blurHandler}

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.

Posted on by:

garryxiao profile

Garry Xiao

@garryxiao

From China, living in NZ now, a startup founder, architect, senior software developer and team lead

Discussion

markdown guide