React Nested and Wildcard Validation
In this section we will go through a in-depth example on how to validate complex forms that contain nested objects and array of objects.
The form that we aim to validate looks like the following.
Components Structure
To be able to proceed with the implementation, the form needs to be divided into components. The image below shows the different components used for the example.
style.css
.mr-10 {
margin-right: 10px;
}
.mr-20 {
margin-right: 20px;
}
.mb-20 {
margin-bottom: 20px;
}
.cl-red {
color: red;
}
.inline {
display: inline-block;
}
App.js
The role of App.js
component is to set the initial data, rules and custom messages and pass them to the Provider
that will wrap
the Form
component.
warning
The Provider
and Form
component will be created in the next steps.
import React from 'react';
import { Password, ruleIn } from 'simple-body-validator';
import { Provider } from './context/FormContext';
import Form from './components/Form';
import './style.css';
// Define the initial data
const data = {
email: '',
password: '',
profile: {
firstName: '',
lastName: '',
gebder: '',
socialPlatforms: [],
addresses: [
{
street: '',
city: '',
zipCode: '',
}
]
}
};
// Define the validation rules
const rules = {
email: 'required|email',
password: [ 'required', Password.default() ],
profile: {
firstName: 'required|string|min:3|max:30',
lastName: 'required|string|min:3|max:30',
// Gender must match one of the predefined values
gender: [
'required', ruleIn(['Female', 'Male', 'Other'])
],
// The social platform must an array of min 2 items, max 3 items,
// and each item must match one of the predefined values
socialPlatforms: 'bail|array|min:2|max:4',
'socialPlatforms.*': ruleIn(['Facebook', 'Instagram', 'Linkedin', 'Twitter']),
// The address must be an array with at least one item
addresses: 'required|array|min:1',
// Each item in the addresses array must be an object,
'addresses.*': 'object',
// validate the attributes for each address object
'addresses.*.street': 'required|string|min:5|max:255',
// The city is required when zip code doesn't exist
'addresses.*.city': [
'required_without:profile.addresses.*.zipCode',
'string', 'min:5', 'max:255'
],
// The zip Code is required when city doesn't exist
'addresses.*.zipCode': [
'required_without:profile.addresses.*.city', 'digits:5'
],
}
};
// Define the custom messages
const customMessages = {
socialPlatforms: {
min: 'You must at least select :min platforms.'
}
};
export default () => {
return (
<div>
<Provider
initialData={data}
rules={rules}
customMessages={customMessages}
>
<Form />
</Provider>
</div>
);
};
If you take a look at the rules
object you will notice that it matches exactly the structure of the data
object. Additionally, to validate the array attributes we used the *
notation, and in the city validation you might have
noticed the following.
'required_without:profile.addresses.*.zipCode',
In the required_without
rule we specified the full path of the attribute, otherwise the library will not be able to
map the rule to the correct attribute value. You can find an introduction on Nested and Wildcard rules here.
FormContext.js
The first step to be done before creating more components, is to create the Form context
that will hold the form's data and errors,
alongside methods that will help updating the state.
Go ahead, create the context
directory and add to it the FormContext.js
file.
info
The example will use lodash clonedeep
and set
methods, so you need to run the following command
npm install lodash.clonedeep lodash.set
import React, { createContext, useReducer, useState } from 'react';
import { make } from 'simple-body-validator';
import clonedeep from 'lodash.clonedeep';
import set from 'lodash.set';
// This reducer is in charge of updating the state of the form data
const formReducer = (state, {type, payload}) => {
// Deep clone the current state
let updatedState = clonedeep(state);
switch(type) {
case 'add_platform':
// push the selected platform to the socialPLatforms array
updatedState.profile.socialPlatforms.push(payload);
return updatedState;
case 'remove_platform':
// remove the deselected platform from the socalPLatforms array
updatedState.profile.socialPlatforms.splice(updatedState.profile.socialPlatforms.indexOf(payload));
return updatedState;
case 'add_address':
// Add a new address object to the addresses list
updatedState.profile.addresses.push(payload);
return updatedState;
case 'remove_address':
// remove the address from the addresses list
updatedState.profile.addresses.pop();
return updatedState;
case 'handle_change':
// update input changes
const { path, value } = payload;
set(updatedState, path, value);
return updatedState;
default:
return state;
}
};
const Context = createContext();
const Provider = props => {
const { children, initialData, rules, customMessages = {} } = props;
// create a new Validator instance
const validator = make(initialData, rules, customMessages);
const [ data, dispatch ] = useReducer(formReducer, initialData);
const [ errors, setErrors] = useState(validator.errors());
// Add a new address to the addresses list
const addAddress = () => dispatch({
type: 'add_address',
payload: {
street: '',
city: '',
zipCode: '',
}
});
// Remove address from the addresses list
const removeAddress = () => dispatch({ type: 'remove_address' });
// Handle input changes
const handleChange = ({ target: { name, value, type, checked }}) => {
if (type === 'checkbox') {
checked ? dispatch({ type: `add_${name}`, payload: value }) :
dispatch({ type: `remove_${name}`, payload: value });
return;
}
dispatch({ type: 'handle_change', payload: { path: name, value }});
};
// Trigger form data validation
const runValidation = () => {
if (! validator.setData(data).validate()) {
setErrors(validator.errors());
return false;
};
setErrors(validator.errors());
return true;
};
return (
<Context.Provider
value={{
data,
errors,
addAddress,
removeAddress,
handleChange,
runValidation,
validator
}}
>
{ children }
</Context.Provider>
);
};
export { Context, Provider };
caution
You are not bounded to React context
for application state handling, you can manage the state the way it fits you the most.
Form.js
Let's create the Form.js
file in the components
directory.
import React, { useContext } from 'react';
import { Context } from '../context/FormContext';
import Account from './Account';
import Profile from './Profile';
export default () => {
const { runValidation } = useContext(Context);
const handleSubmit = event => {
event.preventDefault();
if (!runValidation()) {
return;
}
console.log('Data submitted successfully');
};
return (
<form onSubmit={handleSubmit}>
<div>
<Account />
<Profile />
</div>
<input type="submit" value="Submit" />
</form>
);
};
warning
The Form.js
component will not work directly because we didn't create the Account
and Profile
components yet.
Account.js
import React, { useContext } from 'react';
import { Context } from '../context/FormContext';
const Account = () => {
const {data, handleChange, errors} = useContext(Context);
return (
<div>
<h2>Account</h2>
<div className="mb-20">
<label className="mr-10" htmlFor="email">Email</label>
<input
id="email"
type="email"
name="email"
value={data.email}
onChange={handleChange}
/>
{ errors.has('email') && <p className="cl-red ">{errors.first('email')}</p> }
</div>
<div className="mb-20">
<label className="mr-10" htmlFor="password">Password</label>
<input
id="password"
type="password"
name="password"
value={data.password}
onChange={handleChange}
/>
{ errors.has('password') && <p className="cl-red">{errors.first('password')}</p>}
</div>
</div>
);
};
export default Account;
Profile.js
import React, { useContext } from 'react';
import { Context } from '../context/FormContext';
import Gender from './Gender';
import SocialPlatforms from './SocialPlatforms';
import AddressesList from './AddressesList';
const Profile = () => {
const {data: { profile }, handleChange, errors} = useContext(Context);
return (
<div>
<h2>Profile</h2>
<div>
<div className="mb-20 mr-20 inline">
<label className="mr-10" htmlFor="firstName">First Name</label>
<input
id="firstName"
type="text"
name="profile.firstName"
value={profile.firstName}
onChange={handleChange}
/>
{ errors.has('profile.firstName') && <p className="cl-red">{errors.first('profile.firstName')}</p>}
</div>
<div className="mb-20 inline">
<label className="mr-10" htmlFor="lastName">Last Name</label>
<input
id="lastName"
type="text"
name="profile.lastName"
value={profile.lastName}
onChange={handleChange}
/>
{ errors.has('profile.lastName') && <p className="cl-red">{errors.first('profile.lastName')}</p>}
</div>
</div>
<Gender selectedGender={profile.gender} />
<SocialPlatforms socialPlatforms={profile.socialPlatforms} />
<AddressesList addresses={profile.addresses} />
</div>
)
};
export default Profile;
warning
The Profile.js
will not work directly because we didn't create the Gender
, SocialPlatforms
and AddressList
components.
If you take a look at the JSX part of the Profile
component, you will notice that when we checked if the error exists, we followed the same
path specified in the rules object.
{ errors.has('profile.firstName') && <p className="cl-red">{errors.first('profile.firstName')}</p>}
Gender.js
import React, { useContext } from 'react';
import { Context } from '../context/FormContext';
const Gender = ({ selectedGender }) => {
const { handleChange, errors } = useContext(Context);
return (
<div className="mb-20">
<label className="mr-10">Gender:</label>
{['Female', 'Male', 'Other'].map(gender => (
<span key={gender}>
<input
type="radio"
id={gender}
name="profile.gender"
value={gender}
onChange={handleChange}
checked={selectedGender === gender}
/>
<label className="mr-10" htmlFor={gender}>{gender}</label>
</span>
))}
{ errors.has('profile.gender') && <p className="cl-red">{errors.first('profile.gender')}</p> }
</div>
);
};
export default Gender;
SocialPlatforms.js
import React, { useContext } from 'react';
import { Context } from '../context/FormContext';
const SocialPlatforms = ({ socialPlatforms }) => {
const { handleChange, errors } = useContext(Context);
return (
<div className="mb-20">
<h5>Social Platforms:</h5>
<div>
{['Facebook', 'Instagram', 'Twitter', 'Linkedin'].map(socialPlatform => (
<span key={socialPlatform}>
<input
type="checkbox"
id={socialPlatform}
name="platform"
value={socialPlatform}
className="mr-10"
onChange={handleChange}
checked={socialPlatforms.indexOf(socialPlatform) === -1 ? false : true}
/>
<label key={socialPlatform} htmlFor={socialPlatform} className="mr-10">{socialPlatform}</label>
</span>
))}
</div>
{ errors.has('profile.socialPlatforms') && <p className="cl-red">{ errors.first('profile.socialPlatforms') }</p> }
</div>
);
};
export default SocialPlatforms;
AddressesList.js
import React, { useContext } from 'react';
import { Context } from '../context/FormContext';
import Address from './Address';
const AddressesList = ({ addresses }) => {
const { addAddress, removeAddress } = useContext(Context);
const list = addresses.map((address, index) =>(
<div key={index}>
<h5>Address {index + 1}</h5>
<Address { ...address } index={index}/>
</div>
));
return (
<div className="mb-20">
<h3>Addresses</h3>
{list}
<button type="button" className="mr-10" onClick={addAddress}>Add Address</button>
{ addresses.length > 1 && <button type="button" onClick={removeAddress}>Remove Address</button> }
</div>
);
}
export default AddressesList;
In the example above, the index value was passed from the AddressesList
component into the Address
component.
The index will be used to identify the appropriate error messages associated with each address.
Address.js
import React, { useContext } from 'react';
import { Context } from '../context/FormContext';
const Address = ({ index, street, city, zipCode }) => {
const { errors, handleChange } = useContext(Context);
return (
<div>
<div className="mb-20">
<label className="mr-10" htmlFor={`street.${index}`}>Street</label>
<input
id={`street.${index}`}
type="text"
name={`profile.addresses.${index}.street`}
value={street}
onChange={handleChange}
/>
{
errors.has(`profile.addresses.${index}.street`) &&
<p className="cl-red">{errors.first(`profile.addresses.${index}.street`)}</p>
}
</div>
<div className="mb-20">
<label className="mr-10" htmlFor={`city.${index}}`}>City</label>
<input
id={`city.${index}`}
type="text"
name={`profile.addresses.${index}.city`}
value={city}
onChange={handleChange}
/>
{
errors.has(`profile.addresses.${index}.city`) &&
<p className="cl-red">{errors.first(`profile.addresses.${index}.city`)}</p>
}
</div>
<div className="mb-20">
<label className="mr-10" htmlFor={`zipCode.${index}}`}>Zip Code</label>
<input
id={`zipCode.${index}`}
type="text"
name={`profile.addresses.${index}.zipCode`}
value={zipCode }
onChange={handleChange}
/>
{
errors.has(`profile.addresses.${index}.zipCode`) &&
<p className="cl-red">{errors.first(`profile.addresses.${index}.zipCode`)}</p>
}
</div>
</div>
);
};
export default Address;
To be able to get the correct error message for the fields in the Address
component, we followed the same path
of the rules
object, and we replaced *
with the index of the array.
profile: {
'addresses.*.street': 'required|string|min:5|max:255',
}
errors.first(`profile.addresses.${index}.street`);