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;
Links:
If you liked the content, please leave some likes ๐คโค๏ธ or unicorns ๐ฆ
Pietro Piraino ๐ฎ๐น๐