Taming forms with React and Formik

In my last blog post, I was talking about redux-sagas, how cool they are and why I like them. That was a year ago. A lot has changed since then. Right now, everyone’s hooked on React hooks 🙂, and I’m on a new team working on cool stuff.

The web app I currently work on has a lot of user interaction, mainly capturing data and going on different journeys based on different products or data they are capturing. Capturing data means forms, forms everywhere!

In general, building forms is simple, you have input fields, dropdowns & radio buttons. You have some sort of validation and submit to some API endpoint.

For simple applications (pet projects), this is easy to do. However, on large-scale applications, how do you go about building these forms? There’s probably a lot of great ways to go about doing this, however, since this my blog I want to share a way in which we solved it within our team.

To help me tell the story I will be using a time booking web app called “Time with Tom“. Tom is a popular guy and therefore everyone wants to book time with Tom. This web app allows Tom to manage all his social interaction.

  1. Users can access Tom’s schedule, and they can see when Tom is available for bookings.
  2. Then users can capture a booking request with the following:
    • date
    • time
    • an activity they want to do with Tom
    • name
    • surname
    • email

Making a booking

Time with Tom uses the following:

  • React
  • Formik
  • React-Router
  • React Material-ui

Our focus is Formik.

What Formik?
Formik is a React package which helps you build forms in React. The Formik slogan is: Build forms in React, without the tears.
More info.

In addition, I recommend doing the simple Formik tutorial on the getting started page.

The basic design for this form would look like this:

This basic design would be okay for a pet project but on a larger-scale application, this would be difficult to scale.

When you look at this form, it may be split into the following groupings:

  • User
  • Activity
  • Booking

From these groups you may create React Components from them:

  • <User />
  • <Activity />
  • <Booking />

And if you continue to iterate over this design you can create a Formik Workflow.

What is a Formik Workflow?

Its a Factory Function that always returns an object with the following:

  • A Component function
  • A payload function
  • A validate function
  • A isActive function
  • A initialValues object literal

File: UserWorkflowFactory

import React from 'react';
import User from './component';
import payload from './payload';
import validate from './validate';
import initialValues from './initial-values';
import isActive from './active';

/**
 * User Workflow Factory
 * @param {Object} options
 * @return {Workflow}
 */
const UserWorkflowFactory = ({ ...options }) => {
  return {
    Component: () => <User />,
    payload: (values) => payload(values),
    validate: (values) => validate(values),
    isActive: (values) => isActive(values),
    initialValues,
  };
};

export default UserWorkflowFactory;

Component (Component)

The component is a React component, that houses all the Formik fields for the current workflow. Example User component with Formik fields for:

  • Name
  • Surname
  • Email

Example: <User /> Component
Note: The Component will always use FormikContext to get the values of the fields for the component. FormikContext uses React’s Context API.

Payload (payload)
The payload function extracts the workflow values from the Formik values object. This is used when submitting the Formik form.

Example: payload function
Note: The payload function will always be called with the Formik values object.

Validate (validate)
The validate function validates the current workflow values. Example if User fields are empty. The validate function will return an object with required fields errors.

Example: validate function
Note: The validate function will always be called with the Formik values object.

Is Active (isActive)

The isActive function acts as the business logic section. In this section, we may use whatever logic or data to verify whether our Formik workflow should be active. This will be used by Formik to check whether it needs to do the following for the workflow:

  • display the workflow component with its fields
  • do validation on the workflow
  • or get the mapped payload for the workflow.

Note: The isActive function will always be called with the Formik values object.

Initial Values (initialValues)

The initialValues object literal initialises the Formik form with the workflow fields. Example User fields are:

  • name
  • surname
  • email

Example: initialValues
Note: The initialValues object literal fields are used in all the workflow functions, and React component (payload, validate, Component and isActive).

Benefits of Formik Workflow:

  • Separation of concerns
  • Easy to make changes
  • Easy to tests
  • The payload function can have tests to ensure it’s producing the expected mapped payload.
  • The validate function can also have tests to ensure that the workflow validates correctly.
  • The isActive function can also have tests to ensure that the workflow business rules are correct.

Now with these Formik workflows, how would we use them? You can think of a group of Formik workflows as a feature or Formik Feature.

Time with Tom has a “Booking Request” Formik Feature, which includes the following workflows:

  • Activity Workflow
  • User Workflow
  • Booking Workflow

The BookingRequestFormik feature initialises the Formik form with these workflows.

Example BookingRequestFormik feature workflows are:
File: BookingRequestFormik

const ActivityWorkflow = ActivityWorkflowFactory({});
const UserWorkflow = UserWorkflowFactory({});
const BookingWorkflow = BookingWorkflowFactory({});

const workflows = [ActivityWorkflow, UserWorkflow, BookingWorkflow];

What makes this possible is the following Formik Workflow helpers:

  • getInitialValues function
  • validateWorkflows function
  • buildPayload function

Get Initial Values (getInitialValues)
This takes in Formik workflows array. This is an array of all the initialised Formik workflows for the feature.

It takes all initialValues for each Formik workflow and returns merged initialValues object literal for Formik.

File: getInitialValues

/**
 * get initial values from workflow and sub-worfklow for formik formik
 * @param {Workflow[]} workflows
 * @return {Object} initialValues
 */
export default function getInitialValues(workflows) {
  return workflows
    .map((workflow) => ({ ...workflow.initialValues }))
    .reduce((previousValue, initialValues) => ({
      ...previousValue,
      ...initialValues,
    }));
}

Validate Workflows (validateWorkflows)
This takes in Formik workflows array as well. For each Formik workflow, it calls each validate function with the latest Formik values. The validate function gets called by Formik during the validation.

File: validateWorkflows

/**
 * Validate workflows in parallel
 * @param {Workflow[]} workflows
 * @return {function(Object): Object}
 */
const validateWorkflows = (workflows) => async (values) => {
  const errors = await Promise.all(
    workflows
      .filter((workflow) => workflow.isActive(values))
      .map((workflow) => ({ ...workflow.validate(values) })),
  );

  return errors.reduce((previousErrors, errors) => ({
    ...previousErrors,
    ...errors,
  }));
};

export default validateWorkflows;

Build Payload (buildPayload)
This takes in Formik workflows array as well. For each Formik workflow, it calls each payload function with the latest Formik values. This is called by Formik on submit.

File: buildPayload

/**
 * Build payload for all workflows in parallel
 * @param {Workflow[]} workflows
 * @return {function(Object): Object}
 */
const buildPayload = (workflows) => async (values) => {
  const payload = await Promise.all(
    workflows
      .filter((workflow) => workflow.isActive(values))
      .map((workflow) => ({ ...workflow.payload(values) })),
  );

  return payload.reduce((previousPayload, currentPayload) => ({
    ...previousPayload,
    ...currentPayload,
  }));
};

export default buildPayload;

So when it comes together, a user can book an activity with Tom. If things stay the same then that’s it we are done and this would be the end of this post. However, as we all know in software things always changes, therefore we build software to anticipate change and be able to accommodate these changes.

In Software things always change

Tom has decided that for people to book activities with him, they must pay. If you think of this from a large-scale application perspective you would have to consider the following:

  • how to add a payment Formik workflow to the existing booking request feature?
  • or do you create a new booking request feature with a payment Formik workflow as well?

Whichever method you go with the awesome thing is you know that the existing workflows (User, Activity and Booking) have been working okay so by adding Payment Formik workflow should be as easy as adding a new component.

I’ve decided to create a payment Formik workflow and add it to the existing booking request feature. By doing this, we may also see how features may have their own business rules and how they implement those rules with Formik workflows who are not aware of these rules and simply do whatever they were built to do.

So it turns out Tom only wants to charge for some activities not all of them. This is quite easy to solve, we can just hide the Payment Formik workflow based on this business rule and show a message advising the user that the selected activity requires no payment.

Using Formik context we can retrieve the current values and using those values to validate our business rules. This is an example of the Payment Formik workflow business rule using Formik values to determine if the Payment Formik workflow is active.

File: Payment isActive

import getActivityAmount from '../../activity/util';

/**
 *
 * The isActive acts as the business logic section. In this section we may use whatever logic or data to
 * verify whether our payment-form should be active. This will be used by formik to check whether it needs to do
 * the following for the workflow:
 *  - display the workflow component with its fields
 *  - do the validation
 *  - get the payload
 *
 *  Business Rule(s):
 *  1) Only display payment for paid activities - where amount is greater 0 (all paid items)
 *  2) Do NOT collect payment for free activities - where amount is 0
 * @return {boolean}
 */
const isActive = (values) => getActivityAmount(values) > 0;

export default isActive;

Just as easy as that the booking request will now only show payment options if the activity is a paid one.

Tom is happy as we have met his requirements for only charging for these activities: Golf, Tennis, and Cycling. While Squash and Trail run activities remain free.

Making a booking final with Payment section

Conclusion
Using Formik workflows you get structured modules, which you may plug and play as you please. You may also create sub-workflows as long as you keep the same workflow signature. Are there other ways of taming forms? Please do share with me by kindly sending an email or connecting with me on Github. Thank you.

Source code: https://github.com/zulucoda/time-with-tom
Demo: https://time-with-tom.mfbproject.co.za/booking-request