Creating Your Own React Validation Library: The Developer Experience (Part 3)

Creating Your Own React Validation Library: The Developer Experience (Part 3)

Creating Your Own React Validation Library: The Developer Experience (Part 3)

Kristofer Selbekk

If you’ve been following along this little article series, you’ve now learned how to put together your very own validation library. It can handle almost any challenge you can throw at it, and it even helps out with accessibility concerns! Its only downfall is that it sucks to work with.

Yep, that’s right. The user experience from a developer point of view is seriously lacking. We don’t get any helpful warnings when we misspell words, misuse APIs or, well, anything, really!

This article will guide you through how you can improve the developer experience of your validation library — or any library for that sake.

Starting Out

Since the last part of this article, we’ve pulled out all library code into its own files. Take a look at the CodeSandbox demo to see what we’re starting out with.

Convenience Functions

We want our library to be as simple as possible to use for the most common cases. A way to move towards that goal is to add convenient utility functions for certain functionality.

One such feature could be to check if our form is valid — that is, if all error messages are null. This is something you typically check in your onSubmit handler, but it could be useful in your render-method too. Let’s implement it!

const isFormValid = useMemo(
  () => Object.values(errors).every(error => error === null), 
  [errors]
);

We’ll provide this flag in our onSubmit form handler, as well as in our render method.

There are plenty more of these that could be written, but I’ll let that be an exercise for the reader.

Development Warnings And Invariants

One of React’s greatest features is its many helpful console warnings while developing. We should provide the same sort of quality to our users as well.

To get started, we’ll create two functions — warning for logging warnings to the console, and invariant for throwing an error — both if a given condition is not met.

function warning(condition, message) {
  if (process.env.NODE_ENV === 'production' || condition) {
    return;
  }

  console.warn('useValidation: ' + message);
}
function invariant(condition, message) {
  if (process.env.NODE_ENV === 'production' || condition) {
    return;
  }

  throw new Error('useValidation: ' + message);
}

You want to use invariant if the error is going to crash your library (or render it useless), and warning for bad practices or other advice.

When To Warn

Deciding when to warn is pretty important. Too many, and you’re just annoying. Too few, and you let critical bugs ship to production. Therefore, we need to be smart with our warnings.

Since our library accepts a pretty large configuration object, it makes sense to validate this somehow — at least while developing. We could solve it by using a type system like TypeScript or Flow, but that excludes all regular ol’ JavaScript users.

Instead, let’s create a runtime schema checker, where we validate that the config contains the correct fields, and print relevant warnings.

function validateConfigSchema(config) {
  if (process.env.NODE_ENV === 'production') {
    return;
  }
  if (typeof config === 'function') {
    config = config({});
  }

  invariant(
    typeof config === 'object',
    `useValidation should be called with an object or a function returning an object. You passed a ${typeof config}.`,
  );

  invariant(
    typeof config.fields === 'object',
    'useValidation requires a `field` prop with an object containing the fields and their validators. Please refer to the documentation on usage: https://link.to/docs'
  );

  
  invariant(
    Object.values(config.fields).every(field => typeof field === 'object'),
    'useValidation requires that the `field` object only contains objects. It looks like yours isn\'t. Please refer to the documentation on usage: https://link.to/docs'
  );

  warning(
    ['always', 'blur', 'submit', undefined].includes(config.showError),
    'useValidation received an unsupported value in the `showError` prop. Valid values are "always", "blur" or "submit".'
  )

  // And so on
}

We could probably go on doing this for a while if we wanted to spend the time. And you should! It’s a great way to improve the developer experience of your app.

You don’t have to be writing these by hand, however. There’s a browser-port of the popular object schema validation library joi that could help out with creating a really nice runtime validation check. Also, as previously mentioned, a type system would help catch configuration errors at compile time for the users that use that type system.

Allow For Flexibility

A good developer experience is in large part not getting in the way of the developers. Let’s look at a few ways we can improve that experience.

Compose Conflicting Props

First, our prop getters apply some props to our inputs and forms that can be accidentally overridden by our consumers. Instead, let’s add a prop override object to our prop getters, which will compose any conflicting props together.

Here’s how we can implement this in our getFieldProps:


getFieldProps: (fieldName, overrides = {}) => ({
  onChange: e => {
    const { value } = e.target;
    if (!config.fields[fieldName]) {
      return;
    }
    dispatch({
      type: 'change',
      payload: { [fieldName]: value },
    });
    if (overrides.onChange) {
      overrides.onChange(e);
    }
  },
  onBlur: e => {
    dispatch({ type: 'blur', payload: fieldName });
    if (overrides.onBlur) {
      overrides.onBlur(e)
    }
  },
  name: overrides.name || fieldName,
  value: state.values[fieldName] || '',
}),

A similar approach can be followed in getFormProps.

Help Avoid Prop Drilling

Some forms might be large and split up into several components. Instead of making our consumers’ drill props down the tree, we should provide a context. This way, they can access all the stuff we return from our custom hook anywhere in the tree below.

First, let’s create a ValidationContext with React’s createContext method:

export const ValidationContext = React.createContext({});

Next, let’s create a component ValidationProvider, that provides all the values from the useValidation hook in context instead:

export const ValidationProvider = props => {
  const context = useValidation(props.config);
  return (
    
      {props.children}
    
  );
};

Now, instead of calling useValidation directly, we’d wrap our form in a ValidationProvider component, and get access to the validation props (getFormProps, errors etc) by use of the useContext hook. You’d use it like this:

Import React, { useContext } from 'react';
import { ValidationContext } from './useValidation';

function UsernameForm(props) {
  const { getFieldProps, errors } = useContext(ValidationContext);
  return (
    <>
      <input {...getFieldProps('username')} />
      {errors.username && {errors.username}></span>}
    </>
  );
}

This way, you get the best of both worlds! You get a simple hook for those simple scenarios, and you get the flexibility you need for those complex parts.

Documentation Is Key 🔑

Whenever I’m using a library I didn’t write myself, I love great documentation. But what should you focus on, and where should you document?

A first step should be to put together a simple to understand README, with the most basic usage examples readily available. Andrew Healey wrote an amazing piece on how to write a good README, which I highly recommend you read.

When you’ve created a good README to get people going, a documentation website might be a good idea. Here, you can put a more in-depth API documentation, recipes for typical use cases and a good ol’ FAQ.

There are great tools out there for generating documentation websites. My favorite is docusaurus from Facebook (humble brag: we used it when creating the create-react-app website), but there are several good alternatives out there.

We’re not going to go through how to write good documentation in this article. There are several good articles out there — even a community called “Write the Docs”. They have written a great guide to how you can get started with writing great documentation.

Summary

Through this article series, we’ve created a pretty decent validation library. It has a pretty simple API, flexibility for when you need it, a good developer experience, and a lot of pretty dank features.

We’ve gone through how we implemented things step by step, and I hope you got a deeper understanding of how you can make your own library, and how you make it something people would love to use.

Please let me know in the comments what you think, and if there were some parts you got stuck on or had a hard time understanding. I’ll try my best to update the article as feedback trickles in.

To end this article off — here’s the final version:

Thanks for reading!

Smashing Editorial (dm, yk, il)

Creating Your Own React Validation Library: The Features (Part 2)

Creating Your Own React Validation Library: The Features (Part 2)

Creating Your Own React Validation Library: The Features (Part 2)

Kristofer Selbekk

Implementing a validation library isn’t all that hard. Neither is adding all of those extra features that make your validation library much better than the rest.

This article will continue implementing the validation library we started implementing in the previous part of this article series. These are the features that are going to take us from a simple proof of concept to an actual usable library!

  • Part 1: The Basics
  • Part 2: The Features
  • Part 3: The Experience (Coming up next week)

Only Show Validation On Submit

Since we’re validating on all change events, we’re showing the user error messages way too early for a good user experience. There are a few ways we can mitigate this.

The first solution is simply providing the submitted flag as a returned property of the useValidation hook. This way, we can check whether or not the form is submitted before showing an error message. The downside here is that our “show error code” gets a bit longer:

<label>
  Username
  <br />
  <input {...getFieldProps('username')} />
  {submitted && errors.username && (
    <div className="error">{errors.username}</div>
  )}
</label>

Another approach is to provide a second set of errors (let’s call them submittedErrors), which is an empty object if submitted is false, and the errors object if it’s true. We can implement it like this:

const useValidation = config => {
  // as before
  return {
    errors: state.errors,
    submittedErrors: state.submitted ? state.errors : {},
  };
}

This way, we can simply destructure out the type of errors that we want to show. We could, of course, do this at the call site as well — but by providing it here, we’re implementing it once instead of inside all consumers.

Show Error Messages On-Blur

A lot of people want to be shown an error once they leave a certain field. We can add support for this, by tracking which fields have been “blurred” (navigated away from), and returning an object blurredErrors, similar to the submittedErrors above.

The implementation requires us to handle a new action type — blur, which will be updating a new state object called blurred:

const initialState = {
  values: {},
  errors: {},
  blurred: {},
  submitted: false,
};

function validationReducer(state, action) {
  switch (action.type) {
    // as before
    case 'blur':
      const blurred = { 
        ...state.blurred, 
        [action.payload]: true 
      }; 
      return { ...state, blurred };
    default:
      throw new Error('Unknown action type');
  }
}

When we dispatch the blur action, we create a new property in the blurred state object with the field name as a key, indicating that that field has been blurred.

The next step is adding an onBlur prop to our getFieldProps function, that dispatches this action when applicable:

getFieldProps: fieldName => ({
  // as before
  onBlur: () => {
    dispatch({ type: 'blur', payload: fieldName });
  },
}),

Finally, we need to provide the blurredErrors from our useValidation hook so that we can show the errors only when needed.

const blurredErrors = useMemo(() => {
    const returnValue = {};
    for (let fieldName in state.errors) {
      returnValue[fieldName] = state.blurred[fieldName]
        ? state.errors[fieldName]
        : null;
    }
    return returnValue;
  }, [state.errors, state.blurred]);
return {
  // as before
  blurredErrors,
};

Here, we create a memoized function that figures out which errors to show based on whether or not the field has been blurred. We recalculate this set of errors whenever the errors or blurred objects change. You can read more about the useMemo hook in the documentation.

Time For A Tiny Refactor

Our useValidation component is now returning three sets of errors — most of which will look the same at some point in time. Instead of going down this route, we’re going to let the user specify in the config when they want the errors in their form to show up.

Our new option — showErrors — will accept either “submit” (the default), “always” or “blur”. We can add more options later, if we need to.

function getErrors(state, config) {
  if (config.showErrors === 'always') {
    return state.errors;
  }
  if (config.showErrors === 'blur') {
    return Object.entries(state.blurred)
      .filter(([, blurred]) => blurred)
      .reduce((acc, [name]) => ({ ...acc, [name]: state.errors[name] }), {});
  }
  return state.submitted ? state.errors : {};
}
const useValidation = config => {
  // as before
  const errors = useMemo(
    () => getErrors(state, config), 
    [state, config]
  );

  return {
    errors,
    // as before
  };
};

Since the error handling code started to take most of our space, we’re refactoring it out into its own function. If you don’t follow the Object.entries and .reduce stuff — that’s fine — it’s a rewrite of the for...in code in the last section.

If we required onBlur or instant validation, we could specify the showError prop in our useValidation configuration object.

const config = {
  // as before
  showErrors: 'blur',
};
const { getFormProps, getFieldProps, errors } = useValidation(config);
// errors would now only include the ones that have been blurred
Note On Assumptions
“Note that I'm now assuming that each form will want to show errors the same way (always on submit, always on blur, etc). That might be true for most applications, but probably not for all. Being aware of your assumptions is a huge part of creating your API.”

Allow For Cross-Validation

A really powerful feature of a validation library is to allow for cross-validation — that is, to base one field’s validation on another field’s value.

To allow this, we need to make our custom hook accept a function instead of an object. This function will be called with the current field values. Implementing it is actually only three lines of code!

function useValidation(config) {
  const [state, dispatch] = useReducer(...);
  if (typeof config === 'function') {
    config = config(state.values);
  }
}

To use this feature, we can simply pass a function that returns the configuration object to useValidation:

const { getFieldProps } = useValidation(fields => ({ 
  password: {
    isRequired: { message: 'Please fill out the password' },
  },
  repeatPassword: {
    isRequired: { message: 'Please fill out the password one more time' },
    isEqual: { value: fields.password, message: 'Your passwords don\’t match' }
  }
}));

Here, we use the value of fields.password to make sure two password fields contain the same input (which is terrible user experience, but that’s for another blog post).

Add Some Accessibility Wins

A neat thing to do when you’re in charge of the props of a field is to add the correct aria-tags by default. This will help screen readers with explaining your form.

A very simple improvement is to add aria-invalid="true" if the field has an error. Let’s implement that:

const useValidation = config => {
  // as before
  return {
    // as before
    getFieldProps: fieldName => ({
      // as before
      'aria-invalid': String(!!errors[fieldName]),
    }),
  }
};

That’s one added line of code, and a much better user experience for screen reader users.

You might wonder about why we write String(!!state.errors[fieldName])? state.errors[fieldName] is a string, and the double negation operator gives us a boolean (and not just a truthy or falsy value). However, the aria-invalid property should be a string (it can also read “grammar” or “spelling”, in addition to “true” or “false”), so we need to coerce that boolean into its string equivalent.

There are still a few more tweaks we could do to improve accessibility, but this seems like a fair start.

Shorthand Validation Message Syntax

Most of the validators in the calidators package (and most other validators, I assume) only require an error message. Wouldn’t it be nice if we could just pass that string instead of an object with a message property containing that string?

Let’s implement that in our validateField function:

function validateField(fieldValue = '', fieldConfig, allFieldValues) {
  for (let validatorName in fieldConfig) {
    let validatorConfig = fieldConfig[validatorName];
    if (typeof validatorConfig === ’string') {
      validatorConfig = { message: validatorConfig };
    }
    const configuredValidator = validators[validatorName](validatorConfig);
    const errorMessage = configuredValidator(fieldValue);

    if (errorMessage) {
      return errorMessage;
    }
  }
  return null;
}

This way, we can rewrite our validation config like so:

const config = {
  username: {
    isRequired: 'The username is required',
    isEmail: 'The username should be a valid email address',
  },
};

Much cleaner!

Initial Field Values

Sometimes, we need to validate a form that’s already filled out. Our custom hook doesn’t support that yet — so let’s get to it!

Initial field values will be specified in the config for each field, in the property initialValue. If it’s not specified, it defaults to an empty string.

We’re going to create a function getInitialState, which will create the initial state of our reducer for us.

function getInitialState(config) {
  if (typeof config === 'function') {
    config = config({});
  }
  const initialValues = {};
  const initialBlurred = {};
  for (let fieldName in config.fields) {
    initialValues[fieldName] = config.fields[fieldName].initialValue || '';
    initialBlurred[fieldName] = false;
  }
  const initialErrors = validateFields(initialValues, config.fields);
  return {
    values: initialValues,
    errors: initialErrors,
    blurred: initialBlurred,
    submitted: false,
  };
}

We go through all fields, check if they have an initialValue property, and set the initial value accordingly. Then we run those initial values through the validators and calculate the initial errors as well. We return the initial state object, which can then be passed to our useReducer hook.

Since we’re introducing a non-validator prop into the fields config, we need to skip it when we validate our fields. To do that, we change our validateField function:

function validateField(fieldValue = '', fieldConfig) {
  const specialProps = ['initialValue'];
  for (let validatorName in fieldConfig) {
    if (specialProps.includes(validatorName)) {
      continue;
    }
    // as before
  }
}

As we keep on adding more features like this, we can add them to our specialProps array.

Summing Up

We’re well on our way to create an amazing validation library. We’ve added tons of features, and we’re pretty much-thought leaders by now.

In the next part of this series, we’re going to add all of those extras that make our validation library even trend on LinkedIn. Stay tuned!

Smashing Editorial (dm, yk, il)

Creating Your Own React Validation Library: The Basics (Part 1)

Creating Your Own React Validation Library: The Basics (Part 1)

Creating Your Own React Validation Library: The Basics (Part 1)

Kristofer Selbekk

I’ve always thought form validation libraries were pretty cool. I know, it’s a niche interest to have — but we use them so much! At least in my job — most of what I do is constructing more or less complex forms with validation rules that depend on earlier choices and paths. Understanding how a form validation library would work is paramount.

Last year, I wrote one such form validation library. I named it “Calidation”, and you can read the introductory blog post here. It’s a good library that offers a lot of flexibility and uses a slightly different approach than the other ones on the market. There are tons of other great libraries out there too, though — mine just worked well for our requirements.

Today, I’m going to show you how to write your very own validation library for React. We will go through the process step by step, and you’ll find CodeSandbox examples as we go along. By the end of this article, you will know how to write your own validation library, or at the very least have a deeper understanding of how other libraries implement “the magic of validation”.

  • Part 1: The Basics
  • Part 2: The Features
  • Part 3: The Experience

Step 1: Designing The API

The first step of creating any library is designing how it’s going to be used. It lays the foundation for a lot of the work to come, and in my opinion, it’s the single most important decision you’re going to make in your library.

It’s important to create an API that’s “easy to use”, and yet flexible enough to allow for future improvements and advanced use cases. We’ll try to hit both of these goals.

We’re going to create a custom hook that will accept a single configuration object. This will allow for future options to be passed without introducing breaking changes.

A Note On Hooks

Hooks is a pretty new way of writing React. If you’ve written React in the past, you might not recognize a few of these concepts. In that case, please have a look at the official documentation. It’s incredibly well written, and takes you through the basics you need to know.

We’re going to call our custom hook useValidation for now. Its usage might look something like this:

const config = {
  fields: {
    username: {
      isRequired: { message: 'Please fill out a username' },
    },
    password: {
      isRequired: { message: 'Please fill out a password' },
      isMinLength: { value: 6, message: 'Please make it more secure' }
    }
  },
  onSubmit: e => { /* handle submit */ }
};
const { getFieldProps, getFormProps, errors } = useValidation(config);

The config object accepts a fields prop, which sets up the validation rules for each field. In addition, it accepts a callback for when the form submits.

The fields object contains a key for each field we want to validate. Each field has its own config, where each key is a validator name, and each value is a configuration property for that validator. Another way of writing the same would be:

{
  fields: {
    fieldName: {
      oneValidator: { validatorRule: 'validator value' },
      anotherValidator: { errorMessage: 'something is not as it should' }
    }
  }
}

Our useValidation hook will return an object with a few properties — getFieldProps, getFormProps and errors. The two first functions are what Kent C. Dodds calls “prop getters” (see here for a great article on those), and is used to get the relevant props for a given form field or form tag. The errors prop is an object with any error messages, keyed per field.

This usage would look like this:

const config = { ... }; // like above
const LoginForm = props => {
  const { getFieldProps, getFormProps, errors } = useValidation(config);
  return (
    <form {...getFormProps()}>
      <label>
        Username<br/>
        <input {...getFieldProps('username')} />
        {errors.username && <div className="error">{errors.username}</div>}
      </label>
      <label>
        Password<br/>
        <input {...getFieldProps('password')} />
        {errors.password && <div className="error">{errors.password}</div>}
      </label>
      <button type="submit">Submit my form</button>
    </form>
  );
};

Alrighty! So we’ve nailed the API.

Note that we’ve created a mock implementation of the useValidation hook as well. For now, it’s just returning an object with the objects and functions we require to be there, so we don’t break our sample implementation.

Storing The Form State 💾

The first thing we need to do is storing all of the form state in our custom hook. We need to remember the values of each field, any error messages and whether or not the form has been submitted. We’ll use the useReducer hook for this since it allows for the most flexibility (and less boilerplate). If you’ve ever used Redux, you’ll see some familiar concepts — and if not, we’ll explain as we go along! We’ll start off by writing a reducer, which is passed to the useReducer hook:

const initialState = {
  values: {},
  errors: {},
  submitted: false,
};

function validationReducer(state, action) {
  switch(action.type) {
    case 'change': 
      const values = { ...state.values, ...action.payload };
      return { 
        ...state, 
        values,
      };
    case 'submit': 
      return { ...state, submitted: true };
    default: 
      throw new Error('Unknown action type');
  }
}

What’s A Reducer? 🤔

A reducer is a function that accepts an object of values and an “action” and returns an augmented version of the values object.

Actions are plain JavaScript objects with a type property. We’re using a switch statement to handle each possible action type.

The “object of values” is often referred to as state, and in our case, it’s the state of our validation logic.

Our state consists of three pieces of data — values (the current values of our form fields), errors (the current set of error messages) and a flag isSubmitted indicating whether or not our form has been submitted at least once.

In order to store our form state, we need to implement a few parts of our useValidation hook. When we call our getFieldProps method, we need to return an object with the value of that field, a change-handler for when it changes, and a name prop to track which field is which.

function validationReducer(state, action) {
  // Like above
}

const initialState = { /* like above */ };

const useValidation = config => {
  const [state, dispatch] = useReducer(validationReducer, initialState);
  
  return {
    errors: state.errors,
    getFormProps: e => {},
    getFieldProps: fieldName => ({
      onChange: e => {
        if (!config.fields[fieldName]) {
          return;
        }
        dispatch({ 
          type: 'change', 
          payload: { [fieldName]: e.target.value } 
        });
      },
      name: fieldName,
      value: state.values[fieldName],
    }),
  };
};

The getFieldProps method now returns the props required for each field. When a change event is fired, we ensure that field is in our validation configuration, and then tell our reducer a change action took place. The reducer will handle the changes to the validation state.

Validating Our Form 📄

Our form validation library is looking good, but isn’t doing much in terms of validating our form values! Let’s fix that. 💪

We’re going to validate all fields on every change event. This might not sound very efficient, but in the real world applications I’ve come across, it isn’t really an issue.

Note, we’re not saying you have to show every error on every change. We’ll revisit how to show errors only when you submit or navigates away from a field, later in this article.

How To Pick Validator Functions

When it comes to validators, there are tons of libraries out there that implement all the validation methods you’d ever need. You can also write your own if you want. It’s a fun exercise!

For this project, we’re going to use a set of validators I wrote some time ago — calidators. These validators have the following API:

function isRequired(config) {
  return function(value) {
    if (value === '') {
      return config.message;
    } else {
      return null;
    }
  };
}

// or the same, but terser

const isRequired = config => value => 
    value === '' ? config.message : null;

In other words, each validator accepts a configuration object and returns a fully-configured validator. When that function is called with a value, it returns the message prop if the value is invalid, or null if it’s valid. You can look at how some of these validators are implemented by looking at the source code.

To access these validators, install the calidators package with npm install calidators.

Validate a single field

Remember the config we pass to our useValidation object? It looks like this:

{ 
  fields: {
    username: {
      isRequired: { message: 'Please fill out a username' },
    },
    password: {
      isRequired: { message: 'Please fill out a password' },
      isMinLength: { value: 6, message: 'Please make it more secure' }
    }
  },
  // more stuff
}

To simplify our implementation, let’s assume we only have a single field to validate. We’ll go through each key of the field’s configuration object, and run the validators one by one until we either find an error or are done validating.

import * as validators from 'calidators';

function validateField(fieldValue = '', fieldConfig) {
  for (let validatorName in fieldConfig) {
    const validatorConfig = fieldConfig[validatorName];
    const validator = validators[validatorName];
    const configuredValidator = validator(validatorConfig);
    const errorMessage = configuredValidator(fieldValue);

    if (errorMessage) {
      return errorMessage;
    }
  }
  return null;
}

Here, we’ve written a function validateField, which accepts the value to validate and the validator configs for that field. We loop through all of the validators, pass them the config for that validator, and run it. If we get an error message, we skip the rest of the validators and return. If not, we try the next validator.

Note: On validator APIs

If you choose different validators with different APIs (like the very popular validator.js), this part of your code might look a bit different. For brevity’s sake, however, we let that part be an exercise left to the reader.

Note: On for…in loops

Never used for...in loops before? That’s fine, this was my first time too! Basically, it iterates over the keys in an object. You can read more about them at MDN.

Validate all the fields

Now that we’ve validated one field, we should be able to validate all fields without too much trouble.

function validateField(fieldValue = '', fieldConfig) {
  // as before
}

function validateFields(fieldValues, fieldConfigs) {
  const errors = {};
  for (let fieldName in fieldConfigs) {
    const fieldConfig = fieldConfigs[fieldName];
    const fieldValue = fieldValues[fieldName];

    errors[fieldName] = validateField(fieldValue, fieldConfig);
  }
  return errors;
}

We’ve written a function validateFields that accepts all field values and the entire field config. We loop through each field name in the config and validate that field with its config object and value.

Next: Tell our reducer

Alrighty, so now we have this function that validates all of our stuff. Let’s pull it into the rest of our code!

First, we’re going to add a validate action handler to our validationReducer.

function validationReducer(state, action) {
  switch (action.type) {
    case 'change':
      // as before
    case 'submit':
      // as before
    case 'validate': 
      return { ...state, errors: action.payload };
    default:
      throw new Error('Unknown action type');
  }
}

Whenever we trigger the validate action, we replace the errors in our state with whatever was passed alongside the action.

Next up, we’re going to trigger our validation logic from a useEffect hook:

const useValidation = config => {
  const [state, dispatch] = useReducer(validationReducer, initialState);

  useEffect(() => {
    const errors = validateFields(state.fields, config.fields);
    dispatch({ type: 'validate', payload: errors });
  }, [state.fields, config.fields]);
  
  return {
    // as before
  };
};

This useEffect hook runs whenever either our state.fields or config.fields changes, in addition to on first mount.

Beware Of Bug 🐛

There’s a super subtle bug in the code above. We’ve specified that our useEffect hook should only re-run whenever the state.fields or config.fields change. Turns out, “change” doesn’t necessarily mean a change in value! useEffect uses Object.is to ensure equality between objects, which in turn uses reference equality. That is — if you pass a new object with the same content, it won’t be the same (since the object itself is new).

The state.fields are returned from useReducer, which guarantees us this reference equality, but our config is specified inline in our function component. That means the object is re-created on every render, which in turn will trigger the useEffect above!

To solve this, we need to use for the use-deep-compare-effect library by Kent C. Dodds. You install it with npm install use-deep-compare-effect, and replace your useEffect call with this instead. This makes sure we do a deep equality check instead of a reference equality check.

Your code will now look like this:

import useDeepCompareEffect from 'use-deep-compare-effect';

const useValidation = config => {
  const [state, dispatch] = useReducer(validationReducer, initialState);

  useDeepCompareEffect(() => {
    const errors = validateFields(state.fields, config.fields);
    dispatch({ type: 'validate', payload: errors });
  }, [state.fields, config.fields]);
  
  return {
    // as before
  };
};
A Note On useEffect

Turns out, useEffect is a pretty interesting function. Dan Abramov wrote a really nice, long article on the intricacies of useEffect if you’re interested in learning all there is about this hook.

Now things are starting to look like a validation library!

Handling Form Submission

The final piece of our basic form validation library is handling what happens when we submit the form. Right now, it reloads the page, and nothing happens. That’s not optimal. We want to prevent the default browser behavior when it comes to forms, and handle it ourselves instead. We place this logic inside the getFormProps prop getter function:

const useValidation = config => {
  const [state, dispatch] = useReducer(validationReducer, initialState);
  // as before
  return {
    getFormProps: () => ({
      onSubmit: e => {
        e.preventDefault();
        dispatch({ type: 'submit' });
        if (config.onSubmit) {
          config.onSubmit(state);
        }
      },
    }),
    // as before
  };
};

We change our getFormProps function to return an onSubmit function, that is triggered whenever the submit DOM event is triggered. We prevent the default browser behavior, dispatch an action to tell our reducer we submitted, and call the provided onSubmit callback with the entire state — if it’s provided.

Summary

We’re there! We’ve created a simple, usable and pretty cool validation library. There’s still tons of work to do before we can dominate the interwebs, though.

Stay tuned for Part 2 next week!

Smashing Editorial (dm, il)