Skip to content

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Generating Dynamic Forms from JSON in React

Jun 26, 2020 • 13 Minute Read

Introduction

Creating forms is a boring but unavoidable task in web app implementation. In most practical apps, you are required to implement at least two forms (login, registration). Although this is manageable with a small number of form elements, if the app involves lengthy, complex forms, this soon starts to wear you down.

As a result, the overall build quality degrades. For example, the level of validation put into code isn't there towards the end. The form elements might be implemented clumsily. To avoid this, we can try to give a bit of thought to automating most parts of a form. In this guide, we'll explore a method for generating a complete React form using a custom JSON schema.

First, let's list the expected feature set.

  1. Generalized form elements. If we take a look at the normal HTML input elements, we see that the usage interfaces hugely differ from one component another. For example, the text input is created as <input type="text" /> while a select is created as <select><option></option></select>. So we need to create a consistent API for all form elements.

  2. Handling input and value changes. In React, handling the state of form elements individually is a tiring task. If we declare handlers for each input item, our parent component soon gets cluttered with boilerplate code. To mitigate this, our implementation should be capable of handling form input and submission details with changes in the parent component.

  3. Validations. Input validation and error feedback are two of the most overlooked features that are critical for a smooth UX. Our implementation should inherently support validation and provide feedback to the user.

Now that the expectation is clear, let's dive into the implementation.

Defining Form Elements

Considering the requirement set above, it's apparent that form state management should be managed internally in our implementation. You could either build this from scratch or use an existing library such as Formik. In this guide, the latter is used. You can install Formik by running npm install --save formik.

Formik provides several useful features, including:

  1. Internal management of form state and input value changes
  2. A consistent API across most form elements
  3. An extensible set of base components to create custom components that aren't available in Formik (ex: datepicker)
  4. Ability to plug in a validation library

With Formik on board, the next step is to define your form element components. For simplicity, this guide will only cover the implementation of text input, drop-down select, and a submit button. But you can easily extend it to support any other form element.

      // FormElements.jsx

import React from 'react';
import {
    Formik,
    Form as FormikForm,
    Field,
    ErrorMessage,
    useFormikContext,
    useField,
    useFormik
} from 'formik';

export function Form(props) {
    return (
        <Formik
            {...props}
        >
            <FormikForm className="needs-validation" novalidate="">
                {props.children}
            </FormikForm>
        </Formik>)
}

export function TextField(props) {
    const { name, label, placeholder, ...rest } = props
    return (
        <>
            {label && <label for={name}>{label}</label>}
            <Field
                className="form-control"
                type="text"
                name={name}
                id={name}
                placeholder={placeholder || ""} 
                {...rest}
            />
            <ErrorMessage name={name} render={msg => <div style={{ color: 'red' }} >{msg}</div>} />
        </>
    )
}

export function SelectField(props) {
    const { name, label, options } = props
    return (
        <>
            {label && <label for={name}>{label}</label>}
            <Field
                as="select"
                id={name}
                name={name}
            >
                <option value="" >Choose...</option>
                {options.map((optn, index) => <option value={optn.value} label={optn.label || optn.value} />)}
            </Field>
            <ErrorMessage name={name} render={msg => <div style={{ color: 'red' }} >{msg}</div>} />
        </>
    )
}

export function SubmitButton(props){
    const { title, ...rest } = props;
    const { isSubmitting } = useFormikContext();
    
    return (
        <button type="submit" {...rest} disabled={isSubmitting}>{title}</button>
    )
}
    

If you are not familiar with the Formik components, the above code might be a bit difficult to process. But in brief, the functionality of each component is as follows:

  • Formik - Injects a context called FormikContext into the child components. This is where the form state is saved. All child components can now access it using the useFormikContext hook.
  • Form - A wrapper on HTML form. It automatically handles the submit and reset events.
  • Field - The most important of all. This automatically attaches to the FormikContext and handles the value changes. You only need to provide the name of the field it should update.
  • ErrorMessage - A component that binds to the error fields and shows the error

Apart from the above, you can also see that each field accompanies a label. This is more of a design decision, and you are free to omit it.

With the above set, you can next put these into a React form and test the functionality. The following code shows how to create a basic form on the above elements. The code is quite clean in comparison to using bare metal form elements.

      // App.js

import React, { useState } from 'react';
import './App.css';
import { Form, TextField, SelectField, SubmitButton } from './FormElements';

function App() {

  const [formData, setFormData] = useState({
    name: "",
    email: "",
    role: ""
  });

  const onSubmit = (values, { setSubmitting, resetForm, setStatus }) => {
    console.log(values);
    setSubmitting(false);
  }

  return (
    <div className="App">
          <Form
            enableReinitialize
            initialValues={formData}
            onSubmit={onSubmit}
          >
            <div>
              <TextField 
                name="name"
                label="Name"
              />
            </div>

            <div>
              <TextField 
                name="email"
                label="Email"
              />
            </div>

            <div>
              <SelectField 
                name="role"
                label="Role"
                options={[
                  {
                    label: "Admin",
                    value: "admin"
                  },
                  {
                    label: "User",
                    value: "user"
                  }
                ]}
              />
            </div>

            <SubmitButton
              title="Submit"
            />
          </Form>
    </div>
  );
}

export default App;
    

You can test the form by clicking the Submit button and verifying that the values are returned as expected. The next step is to configure form validations and error handling. Although the form components already have an error field defined, there is no error producing for invalid input. For this to function, a validation mechanism is needed.

Form Validation

Just as in the previous scenario, you could either build your validation logic by hand or employ a third-party library. Yup is one such library that has excellent support for Formik, so this guide will use it. But you are free to experiment with other libraries and see what you prefer.

With Yup, enabling validations is just as easy as defining a JSON schema. The following code defines the schema for the form above. Once defined, the schema can easily be attached to the form.

      // App.js

// ...
import * as Yup from 'yup';
// ...

const FormSchema = Yup.object().shape({
    name: Yup.string()
          .required('Required')
          .min(5, "Required"),
    email: Yup.string().email()
        .required('Required')
        .min(1, "Required"),
    role: Yup.string().oneOf(['admin', 'user'])
          .required('Required')
          .min(1, "Required"),
});

// ...

    <Form
        enableReinitialize
        validationSchema={FormSchema}
        initialValues={formData}
        onSubmit={onSubmit}
    >
    
    ...

    </Form>
    

You can try entering different values and playing with the schema configuration to get an understanding of how it works. Now that the form elements are ready, you can see that there's a repetitive task involved in defining the initial form state and the Yup schema. Moreover, with form elements having a consistent API, it's apparent that the form can be generated.

Generating from JSON

The implementation of the generator is straightforward. Initially, there must be an agreed format for the JSON schema that includes all the information required for the elements. One such format is used in the code below, but you may need to adjust it according to your needs. For instance, if you need customized error messages, this can be part of your schema.

      import React, { useState, useEffect } from 'react';
import './App.css';
import { Form, TextField, SelectField, SubmitButton } from './FormElements';
import * as Yup from 'yup';

const formSchema = {
    name: {
        type: "text",
        label: "Name",
        required: true
    },
    email: {
        type: "email",
        label: "Email",
        required: true
    },
    role: {
        type: "select",
        label: "Role",
        required: true,
        options: [
            {
                label: "Admin",
                value: "admin"
            },
            {
                label: "User",
                value: "user"
            }
        ]
    }
}

function App() {
    const [formData, setFormData] = useState({});
    const [validationSchema, setValidationSchema] = useState({});

    useEffect(() => {   
        initForm(formSchema);
    }, []);

    const initForm = (formSchema) => {
        let _formData = {};
        let _validationSchema = {};

        for(var key of Object.keys(formSchema)){
            _formData[key] = "";

            if(formSchema[key].type === "text"){
                _validationSchema[key] = Yup.string();
            }else if(formSchema[key].type === "email"){
                _validationSchema[key] = Yup.string().email()
            }else if(formSchema[key].type === "select"){
                _validationSchema[key] = Yup.string().oneOf(formSchema[key].options.map(o => o.value));
            }

            if(formSchema[key].required){
                _validationSchema[key] = _validationSchema[key].required('Required');
            }
        }

        setFormData(_formData);
        setValidationSchema(Yup.object().shape({ ..._validationSchema }));
    }

    const getFormElement = (elementName, elementSchema) => {
        const props = {
            name: elementName,
            label: elementSchema.label,
            options: elementSchema.options
        };

        if (elementSchema.type === "text" || elementSchema.type === "email") {
            return <TextField {...props} />
        }

        if (elementSchema.type === "select") {
            return <SelectField  {...props} />
        }

    }

    const onSubmit = (values, { setSubmitting, resetForm, setStatus }) => {
        console.log(values);
        setSubmitting(false);
    }

    return (
        <div className="App">
            <Form
                enableReinitialize
                initialValues={formData}
                validationSchema={validationSchema}
                onSubmit={onSubmit}
            >

                {Object.keys(formSchema).map( (key, ind) => (
                    <div key={key}>
                        {getFormElement(key, formSchema[key])}
                    </div>
                ))}

            </Form>
        </div>
    );
}

export default App;
    

Conclusion

Forms are a mandatory component in most practical web apps. In this guide, we explored a way to simplify dealing with forms in React. First, we created a set of form elements by wrapping the underlying HTML form elements. This provided us with a uniform API across all elements. Next, we defined our specification for describing the form elements and how they should work. Finally, we combined both to generate a form from the schema. In this guide, we only touched on a few components, but we can observe that the method is extensible across any custom form element we might need in the future.