Validating TextFields and Forms is an essential aspect of web application development. This tutorial navigates through various validation techniques, emphasizing the use of React and Material UI (MUI). We will explore both the HTML built-in ‘required’ validation and the creation of custom TextField error validations, providing a robust understanding of form validation strategies.
Here are the main topics that we will cover:
- Implementing MUI TextField Validation: This section will guide you through using HTML’s native ‘required’ and ‘pattern’ attributes.
- Creating Custom Error Validation for MUI TextFields: Add flexibility to your TextField validation by implementing custom error validation.
- MUI Form Validation Strategies: Learn to integrate multiple input fields into a cohesive MUI form validation process, ensuring overall data integrity and user-friendly interactions.
TextField Validation
Since we’re using MUI, the TextField
component comes with handy props for applying styles and displaying error messages out of the box.
<TextField
error
label="Error"
defaultValue="Name 123"
helperText="Incorrect entry."
/>
The error
prop will apply default styling with a red border and label, and the helperText
prop will display the error message below the TextField
.
Now, let’s see how to apply validation using both built-in HTML and custom error validation.
TextField Required Validation
Required
is a built-in HTML attribute that can be used to validate an empty field. We can simply add the required
attribute to the TextField
component to use it.
<TextField required label="Required" />
We must create an onChange
event handler to validate the user input and display the error message when the input field is empty.
Since required
is a built-in HTML validation, we can use the validity.valid
property to check whether the input field is empty.
import React, { useState } from "react";
import { TextField } from "@mui/material";
export default function TextFieldValidation() {
const [name, setName] = useState("");
const [nameError, setNameError] = useState(false);
const handleNameChange = e => {
setName(e.target.value);
if (e.target.validity.valid) {
setNameError(false);
} else {
setNameError(true);
}
};
return (
<TextField
required
label="Required"
value={name}
onChange={handleNameChange}
error={nameError}
helperText={nameError ? "Please enter your name" : ""}
/>
);
}
In the example above, when typing and deleting the input field, the error message will show up. We didn’t add any logic to check if the TextField
is empty because the required
attribute will take care of it! We only need to show the error message if it’s invalid using the helperText
prop.
TextField Pattern Validation
Pattern
is another built-in HTML attribute that can be used for more complex validation. For example, we can enhance the previous validation only to accept letters and spaces.
import React, { useState } from "react";
import { TextField } from "@mui/material";
export default function TextFieldValidation() {
const [name, setName] = useState("");
const [nameError, setNameError] = useState(false);
const handleNameChange = e => {
setName(e.target.value);
if (e.target.validity.valid) {
setNameError(false);
} else {
setNameError(true);
}
};
return (
<TextField
required
label="Required"
value={name}
onChange={handleNameChange}
error={nameError}
helperText={
nameError ? "Please enter your name (letters and spaces only)" : ""
}
inputProps={{
pattern: "[A-Za-z ]+",
}}
/>
);
}
We should see the error message if we try to type in numbers in the example above.
Note that we added the pattern
attribute to the inputProps
because the TexField
component is a wrapper that has the input
element along with other elements. inputProps
will pass the pattern
attribute to the input
element directly.
Custom Error Validation
The previous approach works great but doesn’t necessarily cover all the use cases, and regex can sometimes be tricky to read and maintain. Alternatively, we can add a validation logic in the event handle and display the appropriate error message. Let’s take an “Age” input field as an example. We want to validate that the user is at least 18 years old.
import React, { useState } from "react";
import { TextField } from "@mui/material";
export default function TextFieldValidation() {
const [age, setAge] = useState("");
const [ageError, setAgeError] = useState(false);
const handleAgeChange = e => {
setAge(e.target.value);
if (e.target.value >= 18) {
setAgeError(false);
} else {
setAgeError(true);
}
};
return (
<TextField
label="Age"
value={age}
onChange={handleAgeChange}
error={ageError}
helperText={ageError ? "You must be at least 18 years old" : ""}
/>
);
}
If we try to type in a number less than 18, we should see the error message “You must be at least 18 years old”.
Additionally, we can add different error messages if the age is invalid or negative.
import React, { useState } from "react";
import { TextField } from "@mui/material";
export default function TextFieldValidation() {
const [age, setAge] = useState("");
const [ageError, setAgeError] = useState(false);
const handleAgeChange = e => {
setAge(e.target.value);
if (e.target.value >= 18) {
setAgeError(false);
} else if (e.target.value < 0) {
setAgeError("Age must be a positive number");
} else {
setAgeError("You must be at least 18 years old");
}
};
return (
<TextField
label="Age"
value={age}
onChange={handleAgeChange}
error={ageError}
helperText={ageError}
/>
);
}
The new code should display the error message “Age must be a positive number” if the age is negative.
Form Validation
Now that we know how to validate a single TextField
, let’s move on to validating multiple input fields in a form.
Similar to the previous section, we will cover built-in HTML and custom error validation for the entire form.
Form with Built-in HTML Validation
We can leverage the form onSubmit
event handler for validation when using a form. It has a built-in function called checkValidity()
that returns whether or not the input fields are valid.
Let’s create an example with the following requirements:
- Create a form element with two input fields (Name and Email)
- “Name” will only accept letters and spaces, and “Email” will only accept a valid email address
- Validate the form on submit
- Trigger alert with an error or success message
- Add
noValidate
attribute to the Form to turn off the default HTML validation. Otherwise, the HTML tooltip validation will show up.
import React, { useState } from "react";
import { TextField, Box, Button } from "@mui/material";
export default function FormValidation() {
const handleSubmit = e => {
e.preventDefault();
if (e.target.checkValidity()) {
alert("Form is valid! Submitting the form...");
} else {
alert("Form is invalid! Please check the fields...");
}
};
return (
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
required
label="Name"
inputProps={{
pattern: "[A-Za-z ]+",
}}
/>
<TextField
required
label="Email"
inputProps={{
type: "email",
}}
/>
<Button variant="contained" color="primary" type="submit">
Submit
</Button>
</Box>
);
}
We can see how the handleSubmit
function has the event
object as an argument that allows us to use the checkValidity()
method to check if the form is valid.
When trying the example above, we should see a valid or invalid alert message if we try to submit the form.
Note that we didn’t add a pattern
attribute to the “Email” TextField
because an input with type “email” already has a built-in validation specifically for email addresses!
Let’s update the example to include the onChange
TextField validation.
import React, { useState } from "react";
import { TextField, Box, Button } from "@mui/material";
export default function FormValidation() {
const [name, setName] = useState("");
const [nameError, setNameError] = useState(false);
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState(false);
const handleNameChange = e => {
setName(e.target.value);
if (e.target.validity.valid) {
setNameError(false);
} else {
setNameError(true);
}
};
const handleEmailChange = e => {
setEmail(e.target.value);
if (e.target.validity.valid) {
setEmailError(false);
} else {
setEmailError(true);
}
};
const handleSubmit = e => {
e.preventDefault();
if (e.target.checkValidity()) {
alert("Form is valid! Submitting the form...");
} else {
alert("Form is invalid! Please check the fields...");
}
};
return (
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
required
label="Name"
value={name}
onChange={handleNameChange}
error={nameError}
inputProps={{
pattern: "[A-Za-z ]+",
}}
helperText={
nameError ? "Please enter your name (letters and spaces only)" : ""
}
/>
<TextField
required
label="Email"
value={email}
onChange={handleEmailChange}
error={emailError}
helperText={emailError ? "Please enter a valid email" : ""}
inputProps={{
type: "email",
}}
/>
<Button variant="contained" color="primary" type="submit">
Submit
</Button>
</Box>
);
}
Now we have both validation, onChange
and onSubmit
. One is for input field change, and the other is for form validation on submit.
Form with Custom Error Validation
As mentioned earlier, using the custom error validation approach gives us more flexibility and control over the validation logic but also requires more work.
Let’s take the previous example and add a custom error validation for the input change handlers.
Here are the requirements for the example:
- “Name” will only accept letters and spaces with a length between 3 and 20 characters
- “Email” will only accept a valid email address that contains a domain name and ’@’ symbol
const [name, setName] = useState("");
const [nameError, setNameError] = useState("");
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState(false);
const handleNameChange = e => {
setName(e.target.value);
if (e.target.value.length < 3) {
setNameError("Name must be at least 3 characters long");
} else if (e.target.value.length > 20) {
setNameError("Name must be less than 20 characters long");
} else if (!/^[a-zA-Z ]+$/.test(e.target.value)) {
setNameError("Name must contain only letters and spaces");
} else {
setNameError(false);
}
};
const handleEmailChange = e => {
setEmail(e.target.value);
if (!/^[a-zA-Z0-9._:$!%-]+@[a-zA-Z0-9.-]+.[a-zA-Z]$/.test(e.target.value)) {
setEmailError("Invalid email address");
} else {
setEmailError(false);
}
};
We can remove the pattern
and required
attributes for the elements because now we’re handling the validation logic in the event handler.
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
label="Name"
value={name}
onChange={handleNameChange}
error={nameError}
helperText={nameError}
/>
<TextField
label="Email"
value={email}
onChange={handleEmailChange}
error={emailError}
helperText={emailError}
/>
<Button variant="contained" color="primary" type="submit">
Submit
</Button>
</Box>
Finally, we need to update the handleSubmit
event handler to check if the form is valid or not:
const handleSubmit = e => {
e.preventDefault();
if (nameError || emailError) {
alert("Form is invalid! Please check the fields...");
} else {
alert("Form is valid! Submitting the form...");
}
};
Here are all the pieces put together.
import React, { useState } from "react";
import { TextField, Box, Button } from "@mui/material";
export default function FormValidation() {
const [name, setName] = useState("");
const [nameError, setNameError] = useState("");
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState(false);
const handleNameChange = e => {
setName(e.target.value);
if (e.target.value.length < 3) {
setNameError("Name must be at least 3 characters long");
} else if (e.target.value.length > 20) {
setNameError("Name must be less than 20 characters long");
} else if (!/^[a-zA-Z ]+$/.test(e.target.value)) {
setNameError("Name must contain only letters and spaces");
} else {
setNameError(false);
}
};
const handleEmailChange = e => {
setEmail(e.target.value);
if (!/^[a-zA-Z0-9._:$!%-]+@[a-zA-Z0-9.-]+.[a-zA-Z]$/.test(e.target.value)) {
setEmailError("Invalid email address");
} else {
setEmailError(false);
}
};
const handleSubmit = e => {
e.preventDefault();
if (nameError || emailError) {
alert("Form is invalid! Please check the fields...");
} else {
alert("Form is valid! Submitting the form...");
}
};
return (
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
label="Name"
value={name}
onChange={handleNameChange}
error={nameError}
helperText={nameError}
/>
<TextField
label="Email"
value={email}
onChange={handleEmailChange}
error={emailError}
helperText={emailError}
/>
<Button variant="contained" color="primary" type="submit">
Submit
</Button>
</Box>
);
}
The code example should validate both text field change and form submission.
Code Refactoring and Optimization
The code we’ve written so far works great but could cause some maintainability and performance issues as we scale, especially when having a lot of input fields in the form. Let’s break down the code and see how to refactor and optimize it.
First of all, every time we trigger onChange
, everything is re-rendered. That means if we have ten input fields, every time we type in one of them, all ten input fields will be re-rendered. To avoid that, we can create an isolated component for the TextField
and only re-render that component when the value changes.
const ValidatedTextField = ({ label, validator, onChange }) => {
const [value, setValue] = useState("");
const [error, setError] = useState(false);
const handleChange = e => {
const newValue = e.target.value;
const errorMessage = validator(newValue);
setValue(newValue);
setError(errorMessage);
onChange(!errorMessage);
};
return (
<TextField
label={label}
value={value}
onChange={handleChange}
error={!!error}
helperText={error}
/>
);
};
The component returns a MUI TextField
, but since it’s generic, we must pass a few props to make it work.
label
: The label of theTextField
(in our example, it’s “Email” or “Name”)validator
: A function that takes the value of the input field and returns an error message if the value is invalid or false if the value is validonChange
: A function that takes a boolean value to indicate whether the input field is valid. This is required for the Form validation later on.
Let’s create the two validators for the “Name” and “Email” input fields:
const nameValidator = value => {
if (value.length < 3) return "Name must be at least 3 characters long";
if (value.length > 20) return "Name must be less than 20 characters long";
if (!/^[a-zA-Z ]+$/.test(value))
return "Name must contain only letters and spaces";
return false;
};
const emailValidator = value => {
if (!/^[a-zA-Z0-9._:$!%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]+$/.test(value))
return "Invalid email address";
return false;
};
The function’s logic is the same as the previous example, but instead of setting the error message to the state, we’re returning it. Also, the two functions don’t necessarily need to be in a specific component and can be moved to a separate file if required.
Lastly, in the main FormValidation
component, we can now use the new ValidatedTextField
component instead of the TextField
. Here are the changes we need to make to get it working with the new arrangement:
- Remove all the state variables and event handlers as they are now handled in the
ValidatedTextField
component - Pass in the validators to the
ValidatedTextField
component - Create a new
ref
object to store the validation status of each input field. The object will act as a map where the key is the input field name, and the value is a boolean indicating whether the input field is valid. The reason we useref
instead of the state is that we don’t want to re-render the component every time the validation status changes - Use the
onChange
from the newValidatedTextField
component as a callback to update the validation status in theref
object - Update the
handleSubmit
event handler to check the validation status in theref
object by usingObject.values
andevery
methods
export default function FormValidation() {
const formValid = useRef({ name: false, email: false });
const handleSubmit = e => {
e.preventDefault();
if (Object.values(formValid.current).every(isValid => isValid)) {
alert("Form is valid! Submitting the form...");
} else {
alert("Form is invalid! Please check the fields...");
}
};
return (
<Box component="form" onSubmit={handleSubmit} noValidate>
<ValidatedTextField
label="Name"
validator={nameValidator}
onChange={isValid => (formValid.current.name = isValid)}
/>
<ValidatedTextField
label="Email"
validator={emailValidator}
onChange={isValid => (formValid.current.email = isValid)}
/>
<Button type="submit" variant="contained">
Submit
</Button>
</Box>
);
}
Now, let’s put everything together and see what the final code looks like.
import React, { useState, useRef } from "react";
import { TextField, Box, Button } from "@mui/material";
// ValidatedTextField.js
const ValidatedTextField = ({ label, validator, onChange }) => {
const [value, setValue] = useState("");
const [error, setError] = useState(false);
const handleChange = e => {
const newValue = e.target.value;
const errorMessage = validator(newValue);
setValue(newValue);
setError(errorMessage);
onChange(!errorMessage);
};
return (
<TextField
label={label}
value={value}
onChange={handleChange}
error={!!error}
helperText={error}
/>
);
};
// validators.js
const nameValidator = value => {
if (value.length < 3) return "Name must be at least 3 characters long";
if (value.length > 20) return "Name must be less than 20 characters long";
if (!/^[a-zA-Z ]+$/.test(value))
return "Name must contain only letters and spaces";
return false;
};
const emailValidator = value => {
if (!/^[a-zA-Z0-9._:$!%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]+$/.test(value))
return "Invalid email address";
return false;
};
// FormValidation.js
export default function FormValidation() {
const formValid = useRef({ name: false, email: false });
const handleSubmit = e => {
e.preventDefault();
if (Object.values(formValid.current).every(isValid => isValid)) {
alert("Form is valid! Submitting the form...");
} else {
alert("Form is invalid! Please check the fields...");
}
};
return (
<Box component="form" onSubmit={handleSubmit} noValidate>
<ValidatedTextField
label="Name"
validator={nameValidator}
onChange={isValid => (formValid.current.name = isValid)}
/>
<ValidatedTextField
label="Email"
validator={emailValidator}
onChange={isValid => (formValid.current.email = isValid)}
/>
<Button type="submit" variant="contained">
Submit
</Button>
</Box>
);
}
The outcome is the same as the previous example. Still, now we have a more generic and reusable input field component that can be used without repeatedly writing the same component. As indicated in the comments, we can split the code into separate files for better organization.
Styling and Colors
To customize the colors and other styles of the TextField error state, we can use the Mui-error
class that is added to the TextField when the error prop is true
.
In the following example, we can use the error class to style the helperText
and the input field border and label colors.
...
<TextField
...
sx={{
"& .MuiInputLabel-root.Mui-error": {
color: "#ff8804",
},
"& .MuiOutlinedInput-root.Mui-error .MuiOutlinedInput-notchedOutline": {
border: "3px solid #ff8804",
},
"& .MuiFormHelperText-root.Mui-error": {
color: "#ff8804",
},
}}
/>
Conclusion
In this tutorial, we learned how to validate MUI TextField and Form using built-in HTML and custom error validation with code refactoring and optimization. Although plenty of useful libraries can help us with the validation, it’s always good to understand the underlying principles and build our own validation logic.
Bye for now 👋