React Forms & input custom hook

React Forms & input custom hook

ยท

5 min read

At first glance, forms might seem easy, but actually they are not. In fact, forms and their inputs can assume many states, for example they can be valid or invalid. In some cases, the form's validation is unknown. This could happen with asynchronous validations, such as server requests to check if a value is available (e.g. email address or username).

Hence, every input can be valid or invalid, and we might need to show a specific error message to the user whenever the input is invalid. In the end, the form is valid only if all its inputs are.

Validation can also be tricky, and forms can either be validated at submission, when an input loses focus, on every keystroke or whenever an input's value changes.

When to use a state handler or a ref to access the form's inputs

It is always important to think about the data final usage. For example, if validation is needed only when the form is submitted, using a ref is the best choice as there is no need to update the state value on every key stroke. However, if instant validation is required, state values are more suitable.

import {useRef, useState} from "react";

const SimpleInput = () => {
    // Ref Option
    const nameInputRef = useRef()

    // State option
    const [enteredName, setEnteredName] = useState("")

    const nameInputChangeHandler = (event) => {
        setEnteredName(event.target.value)
    }

    const formSubmissionHandler = (e) => {
        e.preventDefault()

        // Ref Option
        console.log(enteredName)
        // State option
        const enteredValue = nameInputRef.current.value
        console.log(enteredValue)
    }
    return (
        <form onSubmit={formSubmissionHandler}>
            <div className='form-control'>
                <label htmlFor='name'>Your Name</label>
                <input
                    ref={nameInputRef}
                    type='text'
                    id='name'
                    onChange={nameInputChangeHandler}
                />
            </div>
            <div className="form-actions">
                <button>Submit</button>
            </div>
        </form>
    );
};

export default SimpleInput;

Validation

The simplest form's validation is on submission, where it is possible through a useState variable to check for the user's input and display an error message when needed.

const [enteredName, setEnteredName] = useState("")
const [isValid, setIsValid] = useState(true)

const formSubmissionHandler = (e) => {
        e.preventDefault()
        if (!enteredName.trim() === "") {
            setIsValid(false)
            return
        }
        setIsValid(true)
        setEnteredName("")
    }

Although this syntax works correctly, it is not logically correct, as the initial state for isValid should not be true. On the other side, setting it to false would trigger the error message immediately, providing a bad UX.

A solution could be to add a new state variable that tells us when the input has been touched and bind to the onBlur method.

const [isTouched, setIsTouched] = useState(false)

const nameInputBlurHandler = () => {
        setIsTouched(true)
    }

Likewise, we can add validation on every keyStroke by using the onChange method. Hereby a complete working code example using all these methods:

import {useState} from "react";

const SimpleInput = () => {
    const [enteredName, setEnteredName] = useState("")
    const [isTouched, setIsTouched] = useState(false)

    const enteredNameIsValid = enteredName.trim() !== ""
    const nameInputIsInvalid = !enteredNameIsValid && isTouched

    const nameInputChangeHandler = (event) => {
        setEnteredName(event.target.value)
    }

    const nameInputBlurHandler = () => {
        setIsTouched(true)
    }

    const formSubmissionHandler = (e) => {
        e.preventDefault()
        setIsTouched(true)
        if (!enteredNameIsValid) {
            return
        }
        setEnteredName("")
        setIsTouched(false)
    }

    const nameInputClasses = nameInputIsInvalid ? 'form-control  invalid' : 'form-control'
    return (
        <form onSubmit={formSubmissionHandler}>
            <div className={nameInputClasses}>
                <label htmlFor='name'>Your Name</label>
                <input
                    type='text'
                    id='name'
                    onChange={nameInputChangeHandler}
                    onBlur={nameInputBlurHandler}
                    value={enteredName}
                />
                {nameInputIsInvalid && <p className="error-text">Name must not be empty.</p>}
            </div>
            <div className="form-actions">
                <button>Submit</button>
            </div>
        </form>
    );
};

export default SimpleInput;

Increased complexity - Working with more inputs

Obviously, it is not ideal to repeat this logic for every input field in our form, so we need to outsource some logic when working with more complex forms. Creating a separate input component that internally manages touched and valid state variables, and then managing the validity of the form through props, can be a reasonable working solution.

However, there is also another solution which involves the creation of a custom hook:

import React, {useState} from 'react';

const useInput = (validateValueFn) => {
    const [enteredValue, setEnteredValue] = useState("")
    const [isTouched, setIsTouched] = useState(false)

    const valueIsValid = validateValueFn(enteredValue)
    const hasError = !valueIsValid && isTouched

    const valueChangeHandler = (event) => {
        setEnteredValue(event.target.value)
    }

    const inputBlurHandler = () => {
        setIsTouched(true)
    }

    const reset = () => {
        setEnteredValue("")
        setIsTouched(false)
    }

    return {
        value: enteredValue,
        isValid: valueIsValid,
        hasError,
        valueChangeHandler,
        inputBlurHandler,
        reset
    }
};

export default useInput;

With the custom hook ready, it is now possible to use it inside the form component and easily work with multiple inputs without creating many different useState variables:

import useInput from "../hooks/use-input";

const SimpleInput = () => {
    const {
        inputBlurHandler: nameBlurHandler,
        valueChangeHandler: nameChangeHandler,
        hasError: nameInputHasError,
        value: enteredName,
        isValid: enteredNameIsValid,
        reset: resetNameInput
    } = useInput((value) => value.trim() !== "")

    const {
        inputBlurHandler: emailBlurHandler,
        valueChangeHandler: emailChangeHandler,
        hasError: emailInputHasError,
        value: enteredEmail,
        isValid: enteredEmailIsValid,
        reset: resetEmailInput
    } = useInput((value) => value.includes("@"))

    let formIsValid;

    if (enteredEmailIsValid && enteredNameIsValid) {
        formIsValid = true
    }


    const formSubmissionHandler = (e) => {
        e.preventDefault()
        if (!formIsValid) {
            return
        }
        resetNameInput()
        resetEmailInput()
    }

    const nameInputClasses = nameInputHasError ? 'form-control invalid' : 'form-control'
    const emailInputClasses = emailInputHasError ? 'form-control invalid' : 'form-control'
    return (
        <form onSubmit={formSubmissionHandler}>
            <div className={nameInputClasses}>
                <label htmlFor='name'>Your Name</label>
                <input
                    type='text'
                    id='name'
                    onChange={nameChangeHandler}
                    onBlur={nameBlurHandler}
                    value={enteredName}
                />
                {nameInputHasError && <p className="error-text">Name must not be empty.</p>}
            </div>
            <div className={emailInputClasses}>
                <label htmlFor='name'>Your Name</label>
                <input
                    type='email'
                    id='email'
                    onChange={emailChangeHandler}
                    onBlur={emailBlurHandler}
                    value={enteredEmail}
                />
                {emailInputHasError && <p className="error-text">This is not a valid Email.</p>}
            </div>
            <div className="form-actions">
                <button>Submit</button>
            </div>
        </form>
    );
};

export default SimpleInput;

(Optional) Refactoring the custom hook by adding useReducer

Adding a useReducer is also a good and powerful alternative to deal with many state values and complex state updating logic. It's not needed here, but can be good practice for the future:

import {useReducer} from 'react';

const initialInputState = {
    value: "",
    isTouched: false
}

const inputStateReducer = (state, action) => {
    if (action.type === "INPUT") {
        return {value: action.value, isTouched: state.isTouched}
    }
    if (action.type === "BLUR") {
        return {value: state.value, isTouched: true}
    }
    if (action.type === "RESET") {
        return {value: "", isTouched: false}
    }
    return initialInputState
}

const useInput = (validateValueFn) => {

    const [inputState, dispatch] = useReducer(inputStateReducer, initialInputState)

    const valueIsValid = validateValueFn(inputState.value)
    const hasError = !valueIsValid && inputState.isTouched

    const valueChangeHandler = (event) => {
        dispatch({type: "INPUT", value: event.target.value})
    }

    const inputBlurHandler = () => {
        dispatch({type: "BLUR"})
    }

    const reset = () => {
        dispatch({type: "RESET"})
    }

    return {
        value: inputState.value,
        isValid: valueIsValid,
        hasError,
        valueChangeHandler,
        inputBlurHandler,
        reset
    }
};

export default useInput;

GitHub repository

LinkedIn Profile

If you liked the content, please leave some likes ๐Ÿค™โค๏ธ or unicorns ๐Ÿฆ„

Pietro Piraino ๐Ÿ‡ฎ๐Ÿ‡น๐Ÿ•

ย