Skip to main content

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.

http://localhost:3000

Account

Profile

Social Platforms:

Addresses

Address 1

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.

React components

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.

App.js
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
FormContext.js
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.

Initial Rule
profile: {
'addresses.*.street': 'required|string|min:5|max:255',
}
Error Message
errors.first(`profile.addresses.${index}.street`);

Full Example Code