DEV Community

Sophie DeBenedetto
Sophie DeBenedetto

Posted on

Custom Form Validation in React with Redux Middleware

This post was originally published on the TuneCore Tech Blog
Redux provides a clean architecture for state management. So why do we continue to muddy our components with complex validation logic? Instead, let's leverage Redux, with the help of some custom middleware!

Redux and State Management

Redux provides a centralized state management system for our React apps. We subscribe our component tree to a central store and state changes are enacted via a data-down-actions-up pattern. Actions are dispatched to the store, the store uses a reducer to change state and broadcast the new state to our components, and the components then re-render.

Letting Redux manage our React application's state means taking (most) of that responsibility away from individual components–– even our big meaty container components. We don't let our components establish complex internal state and we don't weigh these components down with complex logic to update such state. Instead, we use the Redux store to shape our application's state; action creator functions to communicate the need for state changes; reducers to make state changes. So why should we treat our form validation and error handling any differently?

Despite the adherence of so many React developers to the Redux architecture, it's still common to see complex form components that handle their own validations and errors. Let's allow Redux to do what it does best and manage such interactions for us!

The App

Note: You can check out the complete code for this project on GitHub here, and you can play around with a live demo here. Keep in mind that this is simple dummy app and as such does not have a persistence layer. Sorry, we're not really saving your form
responses :(

You may have heard that we can travel to space now. Elon Musk is looking to staff a mission to Mars. All of the world's top astronauts and nerds are competing for a spot on the ship. In order to apply for a position, you have to fill out a pretty complicated, rigorous application form. As the developers behind this form, we need to implement a complex set of form validations.

Here's a look at the behavior we're going for:

Our form validations range from the standard:

  • Without the required fields of name and email, the form cannot be submitted.
  • Email must be a properly formatted email address.

To the more complicated:

  • The email a user supplies must be their official SpaceEx email address––name@space.ex––as only registered SpaceEx members can apply for this mission.
  • If an applicant checks that they do have experience terraforming other planets, they must fill out the "which planets have you terraformed?" text field.
  • The "which planets have you terraformed?" text field cannot contain "Mars"––this is a mission to Mars, we know you didn't terraform it already!

We can imagine that the list of complex form validations could go on and on. Trying to manage all of this in one component, let's say a FormContainer component, will get really messy, really fast. Instead, we'll offload the form validation and the population of error messages to Redux.

Application State

Our app is pretty simple--it displays an astronaut application form and submits that form. Our initial state looks like this:

// client/src/store/initialStates/astronaut.js

{
  astronaut: {
    id: null,
    name: "",
    email: "",
    terraform_experience: false,
    terraform_planets: ""
  }
}
Enter fullscreen mode Exit fullscreen mode

The Component Tree

Our component architecture is also simple. We have a top-level container component: AstronautForm that contains some child components, each of which represent a section of the form.

Here's a simplified look:

client/src/components/AstronautForm.js:

import React                  from 'react';
import { Form, Button}        from 'react-bootstrap'
import { connect }            from 'react-redux';
import { bindActionCreators } from 'redux';
import * as astronautActions  from '../actions/astronautActions';
import AstronautName          from './form/AstronautName';
import AstronautEmail         from './form/AstronautEmail';
import TerraformExperience    from './form/TerraformExperience';
import TerraformPlanets       from './form/TerraformPlanets';

class AstronautForm extends React.Component {
  ...
  render() {
    const {
      id,
      name,
      email,
      terraform_planets,
      terraform_experience
    } = this.props.astronaut;

    return (
      <Form key="astronaut-form" onSubmit={this.submitForm}>
        <AstronautName
          name={name}
          onAttributeUpdate={this.updateAstronautAttributes}/>
        <AstronautEmail
          email={email}
          onAttributeUpdate={this.updateAstronautAttributes}/>
        <TerraformExperience
          terraformExperience={terraform_experience}
          onAttributeUpdate={this.updateAstronautAttributes}/>
        <TerraformPlanets
          terraformExperience={terraform_experience}
          terraformPlanets={terraform_planets}
          onAttributeUpdate={this.updateAstronautAttributes}/>
        <Button type="submit">
          Submit
        </Button>
        <Button onClick={this.clearForm}>
          Clear
        </Button
      </Form>
    )
  }
}

function mapStateToProps(storeState, componentProps) {
  const { astronaut } = storeState;
  return { astronaut };
}

function mapDispatchToProps(dispatch) {
  return { actions: bindActionCreators(astronautActions, dispatch) }
};

export default connect(mapStateToProps, mapDispatchToProps)(AstronautForm);
Enter fullscreen mode Exit fullscreen mode

Our AstronautForm component is the container component. It is connected to Redux and aware of state changes. It uses mapStateToProps to pluck astronaut out of state and make it available as part of the component's props. It contains (get it?) the child components that make up our form:

  • AstronautName: the name field on our form
  • AstronautEmail: the email field on our form
  • TerraformExperience: the terraforming experience checkbox
  • TerraformPlanets: the terraformed planets text field

Managing State with Actions and Reducers

Our Redux architecture handles updates to the astronaut's attributes in state: name, email, terraform experience and terraform planets.

When a user is done filling out a particular form field, we use the onBlur event to dispatch an action that updates the corresponding attribute in state.

Let's take a look at the AstronautName component as an example:

client/src/components/form/AstronautName.js:

import React from 'react';

class AstronautName extends React.Component {
  state = {
    name: ""
  };

  componentWillReceiveProps(nextProps) {
    this.setState({name: nextProps.name});
  };

  onChange = (e) => {
    this.setState({name: e.target.value});
  };

  onBlur = (e) => {
    this.props.onAttributeUpdate(
      { name: this.state.name }
    )
  };

  render() {
    const { name } = this.state;
    return (
      <div>
        <label>Name</label>
        <input
          type="text"
          onBlur={this.onBlur}
          onChange={this.onChange}
          value={name}/>
      </div>
    )
  }
};

export default AstronautName;
Enter fullscreen mode Exit fullscreen mode

We passed in name as a prop from the AstronautForm parent component. We use componentWillReceiveProps to put that in AstronautName's internal state.

We use the onChange event to update AstronautName's state with the updated name. We use the onBlur event to call the onAttributeUpdate function.

This function is passed in as part of props from AstronautForm. AstronautForm defines the function like this:

client/src/components/AstronautForm.js:

...
updateAstronautAttributes = (newAttributes) => {
  this.props.actions.updateAstronautAttributes(newAttributes)
};
Enter fullscreen mode Exit fullscreen mode

We dispatch an action creator function updateAstronautAttributes. Our action looks like this:

client/src/actions/astronautActions.js:

export function updateAstronautAttributes(newAttributes) {
  return {
    type: "UPDATE_ASTRONAUT_ATTRIBUTES",
    newAttributes
  }
}
Enter fullscreen mode Exit fullscreen mode

This action is handled by our astronautReducer like this:

client/src/reducers/astronautReducer.js:

import defaultState from '../store/initialStates/astronaut.js'

export default function astronautReducer(state=defaultState, action) {
  switch(action.type) {
    case "UPDATE_ASTRONAUT_ATTRIBUTES":
      return {...state, ...action.newAttributes}
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

This creates a new version of our application's central state, updating our components accordingly.

Submitting the Form

When a user clicks the "submit" button on our form, we fire the submitForm function, defined in the AstronautForm container component:

client/src/components/AstronautForm.js:

...
submitForm = (e) => {
  e.preventDefault();
  this.props.actions.saveAstronaut(this.props.astronaut);
};
Enter fullscreen mode Exit fullscreen mode

As described in the previous section, every time a user triggers the onBlur event of a particular form field (name, email, terraforming experience, terraforming planets), we dispatch an action to update the corresponding attribute in the application's state. Since the AstronautForm component is connected to Redux via the connect function, every time such a state change occurs, the component will re-render, and call mapStateToProps. Thus ensuring that at any given point in time, when the user hits "submit" the astronaut in this.props.astronaut is up-to-date with the latest changes.

So, our submitForm function just needs to dispatch the saveAstronaut action creator function with an argument of this.props.astronaut.

Our saveAstronaut action needs to send a web request to our API to submit the form. We know that we can't just plop some async code into the middle of an action creator function without the help of middleware. So, we have some custom API middleware that will send the web request for us. If you're unfamiliar with custom async middleware, I strongly recommend checking out the official Redux Middleware documentation, along with this excellent post written by my TuneCore teammate, Charlie Massry.

Our action looks like this:

client/src/actions/astronautActions.js:

export function saveAstronaut(astronaut) {
  return {
    type: "API",
    astronaut
  };
}
Enter fullscreen mode Exit fullscreen mode

And our middleware looks like this:

client/src/middleware/apiMiddleware.js:

import {
  saveAstronautSuccess,
  saveAstronautFailure
} from '../actions/astronautActions';

const apiMiddleware = ({ dispatch, getState}) => next => action => {
  if (action.type !== "API") {
    return next(action)
  }
  fetch('/api/astronauts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      astronaut: action.astronaut
    })
  }).then((response) => {
    return response.json();
  }).catch((error) => {
    dispatch(saveAstronautFailure(error));
  }).then((data) => {
    dispatch(saveAstronautSuccess(data));
  });
};

export default apiMiddleware;
Enter fullscreen mode Exit fullscreen mode

Our middleware gets called by the store before sending an action creator function's return value along to the reducer. If the action has a type of "API", we will use fetch to send our API request. Then, when the promise resolves, we will dispatch another action. For the purpose of this post, we won't worry about our success and failure functions. Suffice it to say that the success function updates state with the saved astronaut and the failure function updates state with some error message.

Now that we understand the overall structure of our React + Redux app, we're ready to tackle our form validations.

Form Validation

There are three categories of form validations we have to deal with for our app to work as expected.

  • Required fields (like name and email)
  • Custom validations that need to run when the form is submitted
  • Custom validations that need to run when an attribute is updated in state

Let's start with the low-hanging fruit: required fields.

Required Fields: Easy HTML5 Validations

Making a field required, and therefore preventing the user from submitting the form without it, is super easy to do with just HTML. We simply add required to the input tag.

client/src/components/form/AstronautName.js:

...
render() {
  const { name } = this.state;
  return (
    <div>
      <label>Name</label>
      <input
        required
        type="text"
        onBlur={this.onBlur}
        onChange={this.onChange}
        value={name}/>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now, when a user clicks "submit" without filling out this field, we'll see this behavior:

Blammo.

We can do the same for our email field for the same effect.

Validate on Submission

Let's move on to some more complex form validations. If a user clicks the checkbox indicating that they do have experience terraforming other planets, we want to require them to fill out the "which planets have you terraformed?" text field.

We can't validate for the presence of terraformed_planets on the blur of the terraformed_experience checkbox. That would cause the error to show up for the terraformed planets field right after they click the checkbox, before the user has a chance to interact with the terraform_planets text field.

We can (and should) validate the terraform_planets text field on the blur of that text field. But what if the user never clicks into that field at all? What if they check the terraform_experience checkbox and then immediately click "submit". We don't want to actually submit the form to the API under those circumstances. We want to perform this validation before we send the web request.

Why We Shouldn't Validate In the Component

We could handle this directly in the component by adding code to our submitForm function in AstronautForm:

Bad Example, Don't Do This:

submitForm = (e) => {
  e.preventDefault();
  if (this.props.astronaut.terraform_experience && !this.props.astronaut_planets {
    this.props.actions.saveAstronaut(this.props.astronaut);
  } else {
    this.setState({
      errors:
        ...this.state.errors,
        terraform_planets: true
      }
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

This approach has a few drawbacks.

  • It requires us to store errors in the AstronautForm component's state. While there isn't anything inherently wrong with this, storing complex state within individual components is exactly what Redux allows us to avoid.
  • We are starting to add complex logic to our component. Currently, we're only looking at just two attributes. But if we really want our component to handle this validation, this code will have to grow to validate every astronaut attribute. Not only is that messy, but it forces the form component's submit function to explod its responsibilities. No longer can it simply submit a form, now it validates the astronaut object stored in props and decides whether it should submit the form or update the component's internal state. Think of your form submission function like a younger sibling that you don't entirely trust to do anything right and wouldn't give a lot of responsibility to (no offense Zoe). Our form submission function should do exactly that––submit a form. It shouldn't be responsible for validating the astronaut or updating state.

Let's let Redux handle both validating the astronaut and tracking astronaut errors.

Tracking Errors in Redux's State

When we first established our state, we established an object that looks like this:

client/src/store/initialStates/astronaut.js:

{
  astronaut: {
    id: null,
    name: "",
    email: "",
    terraform_experience: false,
    terraform_planets: ""
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's expand the astronaut key of state to include errors, tracking an error for each attribute that we want to validate:

{
  astronaut: {
    id: null,
    name: "",
    email: "",
    terraform_experience: false,
    terraform_planets: "",
    errors: {
      name: null,
      email: null,
      terraform_planets: null
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that the astronaut key in Redux's state contains its own errors, we can rely on our astronautReducer to update these errors appropriately. When will we tell our reducer to update the astronaut's errors? Let's return to our use-case: "validating on submit".

Custom Validation Middleware

According to our earlier example, we know that we want to validate the presence of terraform_planets when a user submit the form, if they have checked the terraform_experience box.

We want to perform this validation after the user hits submit, not inside our component, and we want to do the validation before the API request gets sent. If the astronaut is not valid, we don't want to send the API request. Instead, we'll dispatch an action that will tell our reducer to update the appropriate error in state.

How on earth can we plug into the moment in time after the form is submitted and the saveAstronaut action is dispatched, but before the API request is sent? Custom middleware of course!

We'll define some custom validation middleware and we'll add it to our middleware stack before the custom API middleware. That way it will get called before the API middleware gets called, i.e. before the API request is sent.

This diagram illustrates where in the Redux lifecycle our middleware fits in.

custom middleware

Defining the Middleware

We'll define our form validation middleware:

client/src/middleware/formValidationMiddleware.js:

const formValidationMiddleware = ({ dispatch, getState}) => next => action => {
  // validations coming soon!
};

export default formValidationMiddleware;
Enter fullscreen mode Exit fullscreen mode

Adding to the Middleware Stack

We'll add it to the stack before our custom apiMiddleware.

client/src/store/configureStore.js:

import {
  createStore,
  applyMiddleware } from 'redux'
import rootReducer  from '../reducers'
import initialState from './initialState';
import apiMiddleware from '../middleware/apiMiddleware';
import formValidationMiddleware from '../middleware/formValidationMiddleware';

export default function configureStore() {
  return createStore(
    rootReducer,
    initialState,
    applyMiddleware(
      formValidationMiddleware,
      apiMiddleware
    )
  )
}
Enter fullscreen mode Exit fullscreen mode

Now we're ready to code our validation middleware!

Performing the Validations

First things first. We only want to do this validation work if the action that was dispatched is the saveAstronaut action. This is the action that will send the web request, courtesy of our apiMiddleware. So, we'll add an if statement that checks for the "API" action type. If the action does not have that type, we'll return next(action) so that the action will proceed to the reducer.

client/src/middleware/formValidationMiddleware.js:

const formValidationMiddleware = ({ dispatch, getState}) => next => action => {
  if (action.type !== "API") {
    return next(action)
  }
  // validations coming soon!
};

export default formValidationMiddleware;
Enter fullscreen mode Exit fullscreen mode

Okay, on to our validations. We'll run the validations for every astronaut attribute that requires validation. By taking the validation logic out of the component, we are taking the responsibility of deciding whether to not to send the form submission API request out of the component too. We are allowing the component to dispatch the saveAstronaut action, regardless of the presence of any errors. So, we always want to validate all attributes in this middleware.

client/src/middleware/formValidationMiddleware.js:

import { astronautValidationError } from '../actions/astronautActions';
import astronautValidationErrors    from '../utils/astronautValidationErrors';
import astronautIsValid             from '../utils/astronautIsValid';

const formValidationMiddleware = ({ dispatch, getState}) => next => action => {
  if (action.type != = "API") {
    return next(action)
  }
  const { astronaut } = action;
  let errors          = astronautValidationErrors(astronaut)
  if (!astronautIsValid(errors)) {
    dispatch(astronautValidationError(errors))
  } else {
    next(action)
  };
};

export default formValidationMiddleware;
Enter fullscreen mode Exit fullscreen mode

Let's break this down and take a look at some of the helper functions being called here.

First, we grab the astronaut from the action:

const { astronaut } = action;
Enter fullscreen mode Exit fullscreen mode

Then, we build the errors object with the help of a function, astronautValidationErrors.

let errors = astronautValidationErrors(astronaut)
Enter fullscreen mode Exit fullscreen mode

Our goal is to generate an object that looks exactly like the errors sub-key of state's astronaut key, with the values properly reflecting the presence of an error. We want to generate such an object so that we can send it along to the reducer which will use it to update the astronaut's errors in the application's state.

For example, the following errors object would indicate that there is an error with the name attribute, but not the email or terraform_planets attributes.

{
  name: true,
  email: false,
  terraform_planets: false
}
Enter fullscreen mode Exit fullscreen mode

Let's take a look at the astronautValidationErrors function defined in client/src/utils/astronautValidationErrors.js:

import { attributeValidators } from './attributeValidators';

export default function astronautValidationErrors(astronaut) {
  Object.keys(attributeValidators).reduce((errors, validator) => {
    errors[validator] = !attributeValidators[validator](astronaut)
  }, {})
}
Enter fullscreen mode Exit fullscreen mode

This function relies on an object we've imported from another utils/ file, attributeValidators:

export const attributeValidators = {
  name: nameValid,
  email: emailValid,
  terraform_planets: terraformPlanetValid
}

function nameValid(astronaut){
  return astronaut.name && astronaut.name.length > 0;
}

function emailValid(astronaut) {
  return astronaut.email && astronaut.email.split("@")[1] === "space.ex"
}

function terraformPlanetValid(astronaut) {
  const { terraform_experience, terraform_planets } = astronaut;
  if (terraform_experience) {
    return terraform_planets &&
      terraform_planets.length > 0 &&
      !terraform_planets.toLocaleLowerCase().includes("mars");
  } else {
    return true
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we have an object attributeValidators, with keys corresponding to each of the astronaut attribute names and values pointing to our custom validation helper functions.

We use this object in our astronautValidationErrors function to:

  • Look up the validation function by the name of the attribute, call that function,
  • Set that same key in the errors object we are building to false if the validator returns true (indicating that there isn't an error for this attribute) or true if the validator returned false (indicating that there is an error for this attribute).
errors[validator] = !attributeValidators[validator](astronaut)
Enter fullscreen mode Exit fullscreen mode

Super clean and dynamic.

Returning to our middleware, we've produced an object, errors, that contains the keys of the attribute names and the values of true to indicate an invalid attribute or false to indicate no such error.

Now we need to implement some logic. If the errors object contains any true values (i.e. if any of the attributes are invalid), we should not allow our action to proceed to the next middleware––the API middleware. We should instead dispatch a new action that will tell the reducer to update the astronaut's errors in state.

// client/src/middleware/formValidationMiddleware.js
...
if (!astronautIsValid(errors)) {
  dispatch(astronautValidationError(errors))
} else {
  next(action)
}
Enter fullscreen mode Exit fullscreen mode

Here we use another helper function, astronautIsValid. If the astronaut is not valid, we will dispatch the astronautValidtionError action. Otherwise, we will call next(action) and let Redux proceed to pass our action to the API middleware.

Let's take a look at our helper function, astronautIsValid:

// client/src/utils/astronautIsValid.js

export default function astronautIsValid(errors) {
  return !Object.values(errors).some(err => err)
}
Enter fullscreen mode Exit fullscreen mode

It simply returns true if the errors object has no keys with a value of true (which indicates an invalid attribute) and false if the errors object contains any true values.

Back in our middleware, if the errors object does in fact contain true values, we dispatch the astronautValidtionError action with a payload of the errors object we built.

Updating State

The astronautValidtionError action looks like this:

// client/src/actions/astronautActions.js
...
export function astronautValidationError(errors) {
  return {
    type: "ASTRONAUT_VALIDATION_ERROR",
    errors
  }
}
Enter fullscreen mode Exit fullscreen mode

And is handled by the astronautReducer which uses the object contained in action.errors to update the astronaut in state with the appropriate errors:

// client/client/src/reducers/astronautReducer.js
...
case "ASTRONAUT_VALIDATION_ERROR":
  return {
    ...state,
    errors: {
    ...state.errors,
    ...action.errors
  }
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we'll update each component to display an error message if the given attribute has an error.

Let's look at the AstronautEmail component as an example.

Notice that the container component, AstronautForm now passes in the this.props.astronaut.errors.email as a prop.

// client/client/client/src/components/AstronautForm.js
...
render() {
  const { email, errors } = this.props.astronaut;
  ...
  <AstronautEmail
    email={email}
    emailError={errors.email}
    onAttributeUpdate={this.updateAstronautAttributes} />
  ...
}
Enter fullscreen mode Exit fullscreen mode

And our AstronautEmail component implements some display logic based on the presence of emailError in props:

// client/client/src/components/form/AstronautEmail.js
...
render() {
  ...
  {emailError &&
    <div>please provide a valid SpaceEx email.</div>
  }
}
Enter fullscreen mode Exit fullscreen mode

We've successfully validated our form after the user clicked submit, taught Redux to manage errors in application state, prevented the web request from being sent to the API when the astronaut is not valid, and displayed errors in our components––all without adding complicated view logic or state management to our components! Good job us.

Validate on State Change

Now that we've looked at the scenario in which we want to preform validations when we submit the form, let's discuss our last validation use-case. Some validations should occur as the user edits the form––updating the component to display certain errors as soon as the user finishes editing a particular form field.

Our email and "which planets have you terraformed?" fields are good examples of this desired behavior. As soon as a user focuses off of one of these form fields, we should display or remove the appropriate errors. In the case of email, we should show them an error message if they provided a non "@space.ex" email. In the case of terraformed planets, we should show them an error if (1) they clicked "terraforming experience" but left this field blank, or (2) they included "Mars" in their list of planets.

We can see this behavior below:

So, how do we hook into the point in time when we're blurring away from a form field and updating the astronaut's attributes in Redux's state? We already have an action that gets dispatched onBlur of each form field: updateAstronautAttributes. This action sends the new attributes to the reducer where the astronaut is updated in state.

Let's write custom middleware to intercept this action, validate the astronaut against its new attributes, and add errors to the action for the reducer to include in any state changes.

We'll define our middleware and add it to the middleware stack:

client/src/middleware/validateAttributeUpdateMiddleware.js:

const validateAttributeUpdateMiddleware = ({ dispatch, getState}) => next => action => {
  // validations coming soon!
};

export default validateAttributeUpdateMiddleware;
Enter fullscreen mode Exit fullscreen mode
// client/src/store/configureStore.js

import {
  createStore,
  applyMiddleware } from 'redux'
import rootReducer  from '../reducers'
import initialState from './initialState';
import apiMiddleware from '../middleware/apiMiddleware';
import formValidationMiddleware from '../middleware/formValidationMiddleware';
import validateAttributeUpdateMiddleware from '../middleware/ValidateAttributeUpdateMiddleware';

export default function configureStore() {
  return createStore(
    rootReducer,
    initialState,
    applyMiddleware(
      formValidationMiddleware,
      validateAttributeUpdateMiddleware,
      apiMiddleware
    )
  )
}
Enter fullscreen mode Exit fullscreen mode

Now we're ready to code our validations!

client/src/middleware/validateAttributeUpdateMiddleware.js:

import astronautAttribueIsValid from '../utils/astronautAttributeIsValid'

const ValidateAttributeUpdateMiddleware = ({ dispatch, getState}) => next => action => {
  if (action.type !== "UPDATE_ASTRONAUT_ATTRIBUTES") {
    return next(action)
  }
  const { newAttributes } = action;
  const { astronaut }     = getState();
  let updatedAstronaut    = {...astronaut, ...newAttributes}
  const attrName          = Object.keys(newAttributes)[0]
  action.errors = {
    [attrName]: !astronautAttribueIsValid(updatedAstronaut, attrName)
  }
  next(action)
};

export default ValidateAttributeUpdateMiddleware;
Enter fullscreen mode Exit fullscreen mode

Let's break this down:

First, we grab our hash of new attributes from the action:

const { newAttributes } = action;
Enter fullscreen mode Exit fullscreen mode

Then, we build a copy of the astronaut object that is currently in state, with the new attributes:

const { astronaut }     = getState();
let updatedAstronaut    = {...astronaut, ...newAttributes}
Enter fullscreen mode Exit fullscreen mode

Next up, we need to grab the name of the attribute we are currently updating, so that we know which validation helper function to call:

const attrName = Object.keys(newAttributes)[0]
Enter fullscreen mode Exit fullscreen mode

Lastly, we dynamically populate action.errors with a key of the name of the attribute we are updating/validating and a true/false value. We populate this value with the help of another helper function, astronautAttribueIsValid. Let's take a look at that function now:

client/src/utils/astronautAttribueIsValid.js:

import { attributeValidators } from './attributeValidators';

export default function astronautAttributeIsValid(astronaut, attribute) {
  if (attributeValidators[attribute]) {
    return attributeValidators[attribute](astronaut);
  } else {
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

This function takes in arguments of the astronaut object we are validating and the name of the attribute to validate.

Once again we utilize our attributeValidators object and the helper functions it stores. We look up the validation function by its attribute name, if it exists, we call the function with an argument of our astronaut. This will return true for a valid attribute and false for an invalid one.

If our attempts to lookup a validation function in the attributeValidators object returns undefined, then this is an attribute that we don't have a validator for. It doesn't need to be validated and we should just return true to indicate that the attribute is valid (by virtue of not requiring validation, it can't be invalid).

So, in the case in which the astronaut's newAttributes look like this:

{email: "sophie@gmail.com"}
Enter fullscreen mode Exit fullscreen mode

We set action.errors to:

{
  email: true
}
Enter fullscreen mode Exit fullscreen mode

Thereby indicating that the email attribute is invalid.

Updating State

Once we've built our errors object and attached it to action, we return next(action). This will send our action to the reducer in the following state:

{
  type: "UPDATE_ASTRONAUT_ATTRIBUTES",
  newAttributes: {email: "sophie@email.com"},
  errors: {email: true}
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we'll teach our astronautReducer to handle this action correctly by updating not just the astronaut's top-level attributes, but also by updating the astronaut's errors.

// client/src/reducers/astronautReducer.js

...
case "UPDATE_ASTRONAUT_ATTRIBUTES":
  return {
    ...state,
    ...action.newAttributes,
    errors: {
      ...state.errors,
      ...action.errors
    }
  }
...
Enter fullscreen mode Exit fullscreen mode

This will cause the components to re-render with the appropriately updated astronaut mapped into props from state. Our components already contain logic to display any errors found in astronaut.errors so our app should just work!

Conclusion

The code shared here represents just a handful of (contrived and simplified) example use-cases for custom validation middleware. The main take away here is not the particular validation functions for our fictitious astronaut form, but rather the manner in which we leveraged Redux to handle these validations. We avoided creating a bloated container component that was responsible for validations and making decisions about which actions to dispatch under which circumstances. Instead, we let Redux's centralized state management system maintain error states and hooked into the dispatch of different actions to perform custom and complex validations. We kept our components clean, and we let Redux do what it does best.

Top comments (1)

Collapse
 
quesow profile image
Jesua Betancor Alemán

This is the first time I know about Redux Middleware usages! Thanks a lot for your post :)