Build React Forms using JSON

A scalable way to manage forms in your project

ยท

9 min read

Build React Forms using JSON

Struggling to manage forms in your project? What if you could generate your forms from a JSON schema?

Every web application makes use of forms at some point as they are vital for information gathering. These forms can get very large and complex depending on your use case. Ideally, we want to be able to create forms in the simplest way possible.

๐Ÿš The Problem

The React ecosystem already has some very popular libraries to manage forms such as React Hook Form, React Final Form, Formik, and many more. All these libraries provide validation, conditional logic, form submission handling, and everything you need to create complex forms. Although, all of them require a fair amount of JSX to be written which becomes verbose and repetitive, especially with long forms and multiple sections. If your forms are large, they often need to be divided into multiple React component files for better maintainability. I think we can all agree that doing so will be very tedious and time-consuming.

๐Ÿš€ The Solution

Wouldn't it be great to abstract away repetitive and verbose JSX code required to render forms? Just think about it, forms do very standard and predictable tasks. Generally, we need the following:

  • Validation - Is a field required or not, Regex, etc? What error message to display if validation fails?

  • Layout - Whether to place fields horizontally or vertically. Nesting fields under collapse sections.

  • Conditions/Dependencies - Whether to hide or show a field/section when a certain condition is true. Deriving field A value from field B.

  • Form state - Providing the initial state of the form. Perform an action on submitting.

  • Field metadata - Name, label and description of the field. Component to render (TextField, NumberField, Switch, custom component..).

Creating a form should be as simple as defining the structure and properties of the form in JSON format and passing that to a React component that will render the form with all the complex validation, layout, and conditions for us. Data Driven Forms is a React library that enables us to do just that.

Data Driven Forms, as the name suggests, creates React forms using JSON data. Once we set up the library in our React app, all we need to do is define a JSON definition to create a unique form with its validation, layout, fields, conditions, and structure. It can be thought of as a declarative way to build forms. We don't need to specify how to build the form, but only what the user should see and how it should behave.

Data Driven Forms library internally makes use of React Final Form for managing the state of the form state. Although this dependency is likely to be removed in the next version of the library.

โญ๏ธ Benefits of Data Driven approach

  • Build new forms significantly faster

  • Less source code

  • More readable and easy to tell what the form is doing

  • A well-structured and consistent way to create forms

In the future if you choose to adopt a different framework like Svelte, the JSON form definitions can still be used. You would however need to create a component that would be able to render the JSON definition in the new framework as Data Driven Form library only has support for React at the time of writing this blog.

Here is an example of how a JSON definition/schema is structured.

const exampleForm = {
    fields: [
        {
              name: "firstName",
              component: "text-field",
              label: "First Name",
              validate: ...,
              condition: ...,
        },
        ...
    ]
}

๐Ÿง‘๐Ÿผโ€๐Ÿ’ป Using Data Driven Forms

Here is a CodeSandbox for you to view the source code and run it yourself:

Install library

To get started, install the required npm package.

yarn add @data-driven-forms/react-form-renderer

The @data-driven-forms/react-form-renderer package contains the FormRenderer component that is responsible for rendering the form based on the schema it is passed.

We'll also need a component mapper. The @data-driven-forms/ant-component-mapper provides a Component Mapper that maps string literals to Ant Design components. The mapper is passed to FormRenderer as a prop.

yarn add @data-driven-forms/ant-component-mapper antd

You could install one of the other component mapper available too or create your own custom mapper which would allow you to make use of React components already available in your project. At Certa, we have mapped our design system components to build a custom mapper.

FormRenderer

The FormRender is a React component that contains all the logic for rendering a React form according to the schema and the configuration provided to it via props.

// SchemaForm.jsx
import FormRenderer from "@data-driven-forms/react-form-renderer/form-renderer";
import FormTemplate from "@data-driven-forms/ant-component-mapper/form-template";
import componentMapper from "@data-driven-forms/ant-component-mapper/component-mapper";
import { schema } from "./schema";

export const SchemaForm = () => {
  return (
    <FormRenderer
      schema={schema}
      componentMapper={componentMapper}
      FormTemplate={FormTemplate}
      initialValues={{}}
      onSubmit={(values) => console.log(values)}
    />
  );
};

The schema property takes the form JSON definition.

The FormTemplate property defines a template of the form. This is a component that

The initialValues property allows you to set the initial values of the fields in the form.

All the available props can be viewed here.

๐Ÿ“œ JSON Definition

Here is what a typical form schema might look like.

// schema.js
export const schema = {
  fields: [
    {
      component: componentTypes.TEXT_FIELD,
      name: "name",
      label: "Your name",
      isRequired: true,
      validate: [{ type: validatorTypes.REQUIRED }]
    },
    {
      component: componentTypes.TEXT_FIELD,
      name: "email",
      label: "Email",
      isRequired: true,
      validate: [
        {
          type: validatorTypes.PATTERN,
          pattern: "[a-z0-9._%+-]+@[a-z0-9.-]+.[a-z]{2,}$",
          message: "Not valid email"
        }
      ]
    },
    {
      component: componentTypes.TEXT_FIELD,
      name: "confirm-email",
      label: "Confirm email",
      type: "email",
      isRequired: true,
      validate: [{ type: "same-email" }]
    },
    {
      component: componentTypes.TEXT_FIELD,
      name: "address.street",
      label: "Street"
    },
    {
      component: componentTypes.SELECT,
      name: "address.state",
      label: "State",
      options: [
        { label: "Delhi", value: "delhi" },
        { label: "Goa", value: "goa" },
        { label: "Maharashtra", value: "maharashtra" }
      ]
    },
    {
      component: componentTypes.CHECKBOX,
      name: "newsletters",
      label: "I want to receive newsletter"
    }
  ]
};

The name property defines the path where the field data needs to be mapped.

The component property defines the component to be used to render the field from the componentMapper.

The label property is just the label text of that field.

The validate property defines the validation parameters on the field. Regex is also supported.

Depending on the component property, you might need to pass in more properties. For example, the select component also needs dropdown options.

๐ŸŽ› Conditions

Forms fields might need to conditionally be hidden or visible if they satisfy a certain condition. With Data Driven Forms you can easily declare this inside the field schema.

Let's take an example to show visibility constraint in action.

{
    name: "whereInGoa",
    component: "text-field",
    label: "Where in Goa?",
    condition: {
        when: "state",
        is: "goa"
    }
}

Initially this field would be hidden. Only when the field with name "state" has a value of "goa", would this field be visible.

This was a simple example. Data Driven Forms allows for more complex conditions where you can define multiple rules in not, and, or or logic. You can even change the value of the field if the condition satisfies.

๐Ÿ›ก Validators

Validation are important to prevent users from submitting incorrect data.

A common way to validate a field is to use regex. You can use the "pattern" validator to achieve this.

{
    name: "foo",
    ...
    validate: [
        {
            type: validatorTypes.PATTERN,
            pattern: /^Foo$/i,
            message: "This field doesn't match the required format"
        },
    ]
}

message property specifies a custom error message for when the validation fails.

Following are all the validators provided by the library:

const validatorTypes = {
  REQUIRED: 'required';
  MIN_LENGTH: 'min-length';
  MAX_LENGTH: 'max-length';
  EXACT_LENGTH: 'exact-length';
  MIN_ITEMS: 'min-items';
  MIN_NUMBER_VALUE: 'min-number-value';
  MAX_NUMBER_VALUE: 'max-number-value';
  PATTERN: 'pattern'; // regex
  URL: 'url';
}

You can define your custom validator too which is essentially a function that receives the value of the field as the first argument and is expected to return the error message string if validation fails or undefined if the validation passes.

{
    name: "foo",
    ...
    validate:  [(value) => (value === "Pawan" ? '"Pawan is not valid" : undefined)]
}

๐Ÿ—ƒ Custom Component Mapper

Using a predefined component mapper could be a great way to get started, but at some point, we want to use custom components that are already in our project.

Here is how we define a custom component mapper:

// customComponentMapper.js
import { TextField } from "./TextField";

export const customComponentMapper = {
  "text-field": TextField
};

Here the TextField component would contain the custom component prop mapping.

// TextField.jsx
import React from "react";
import { Form } from "antd";
import { useFieldApi } from "@data-driven-forms/react-form-renderer";
import { validationError } from "@data-driven-forms/ant-component-mapper";
import { Input } from "../components/Input";

export const TextField = (props) => {
  const {
    input,
    isReadOnly,
    isDisabled,
    isRequired,
    label,
    helperText,
    description,
    validateOnMount,
    meta,
    ...rest
  } = useFieldApi(props);

  const invalid = validationError(meta, validateOnMount);
  const warning = (meta.touched || validateOnMount) && meta.warning;
  const help = invalid || warning || helperText || description;

  return (
    <Form.Item
      validateStatus={!invalid ? (warning ? "warning" : "") : "error"}
      help={help}
      label={label}
      required={isRequired}
    >
      <Input
        {...input}
        onChange={(e) => input.onChange(e.target.value)}
        defaultValue={input.value}
        disabled={isDisabled || isReadOnly}
        {...rest}
      />
    </Form.Item>
  );
};

The useFieldApi hook is a wrapper around React Final Form useField hook.

In this case, Input component would be the custom component that you want to use as a text field.

If the component itself doesn't have a way to show the required symbol, label or description of a field, a wrapper component like Form.Item from ant design can be used.

We can now spread the customComponentMapper object while passing it into FormRenderer. This would override the predefined component mappers with our custom ones.

<FormRenderer
    ...
    componentMapper={{ ...componentMapper, ...customComponentMapper }}
    ...
/>

๐Ÿ“ FormTemplate

The available component mapper packages offer a predefined FormTemplate component to fit the design language of their respective Design Systems. At Certa, we have our own design system and needed a way to customize the button styles, placements and styling of the container form component. The best way to do this is to define our own custom FormTemplate component.

A very basic custom FormTemplate would look like this:

const FormTemplate = ({ schema, formFields }) => {
  const { handleSubmit } = useFormApi();

  return (
    <form onSubmit={handleSubmit}>
      { formFields }
      <button type="submit">Submit</button>
    </form>
  )
}

๐Ÿ‘‹ Final Words

There is some initial heavy lifting required to setup Data Driven Forms, especially if you have your own design system components. But once the custom component mappers, validators, and FormTemplate are good to go, the form-building effort is significantly low and would save a lot of developer effort and time in the long run.

Hope the Data Driven approach helps you and your team focus less on forms and more on the business logic of your product.

We're hiring!

If solving challenging problems at scale in a fully-remote team interests you, head to our careers page and apply for the position of your liking!

ย