Making a form in React and maintaining it can be painful. Especially if the form is complicated, with lots of sections and subsections. Each time a new change needs to be made, one needs to copy-paste some input tags, setup default values and configure the state of the form. If this sounds like an easy thing to do, wait till your client tells you to put field A from sub-subsection X of subsection Y of section Z inside subsection P of section B! Also, this copypasta pattern goes against the DRY (Don't Repeat Yourself) principle. To get a feel for it, assume you have 100 copy-pasted components, and you need to change the input type of each one of them. Nightmare isn't it?

Understanding the structure

Your whole HTML Webpage, is rendered in the form of a DOM Tree in the browser.

The form is a part of it. The good thing about a Tree Data Structure is that, each of the child nodes that arise from a root node, is a tree in its own right. Hence a form can be modeled as a tree and so can be its subsections.

From Form to Tree

Now, if you compress the value of each input into a simple JS object (which you can use as the state of the form) you get another tree structure, the object-tree.

For the image above, the tree would look like:

{
    a: {
        input1: "value1",
        input2: "value2"
    },
    input3: "value3",
    b: {
        input4: "value4",
        c: {
            input5: "value5"
        }
    }
}

Notice that if you have a consistent component creation scheme, (i.e., you create and style all your input fields in a similar way), this JSON above is all the information you need to create the form.

Enter Recursive React Components

Till now, we haven't seen any React magic happening. Let's look into it now. Algorithmically speaking (checkmate Competitive Programmers!), if we do a Depth First Traversal of the JSON object, generate an appropriate input field for the leaf nodes and generate section headers for the non-leaf nodes, we are done.

Abstract pseudo-code for the above strategy is:


function generate_form(jsonData){
    for (key in jsonData){
        if (jsonData[key] is Object){
            generate_heading(key);
            generate_form(jsonData[key]);
            end_heading(key);
        }else{
            generate_input(name=key, defaultValue=jsonData[key]);
        }
    }
}

React Components are just JS functions. This means that the above pseudo-code function can be directly translated to a functional component.

Wait! But how do I update states?

Well, there can be many approaches to this. But please do not directly modify the state object using references to its child nodes.

Meme

One method that you can follow is this:

  1. Create a deep copy of your state.
  2. Pass this deepcopy to the Recursive Form Component as a scapegoat.
  3. Create a function which when called, simply sets the state to a deep copy of the above mentioned deepcopy (yes, you need to do a copy of copy).
  4. On change of any field in the form, directly assign the new value to the concerned field in the deepcopy of step 2 and fire the function mentioned in step 3.

The component rendering cycle is as follows:

Component Rendering Cycle

The reason why we are copying for the 2nd time is that React does a shallow checking of the states to decide whether to re-render a component. Doing a deep copy, will definitely change the reference of the variable passed. This will force React to rerender.

Talk is cheap ...

So, we are done with our Requirement Analysis and Design, let's dive into the code.

The code I'm going to share here is from a live project, albeit a little modified.

It uses Accordion, Form and Card Components of the Material UI library. I am not exhaustively including the imports to keep the code brief and to the point. Instead of using states, here I have used a Redux store for state management.

First, let me show you the recursive component.

import React from 'react';
function keyToFieldName(key) {
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, function (str) { return str.toUpperCase(); });
}
function GenericForm({ schema, data, setData, name, display, format = keyToFieldName }){
// Schema and data have same structure.
// But schema must be immutable.
// data is the mutable prop (connected to state)
let keys = Object.keys(schema);
let classes = useStyles();
return (
<div className={classes.accordionRoot}>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={name + "-content"}
id={name + "-header"}
>
<Typography className={classes.accordionHeading}>
{format(name)}
</Typography>
</AccordionSummary>
<AccordionDetails>
<div style={{flexDirection: "column"}}>
{keys.map((k) => {
if (display(k)){
if (typeof(schema[k]) === "object"){
return (<GenericForm schema={schema[k]} data={data[k]} format={format} name={k} display={display} setData={setData} />);
}else if (typeof(schema[k]) === "number"){
return (
<TextField
label={format(k)}
defaultValue={data[k]}
type="number"
InputLabelProps={{
shrink: true,
}}
onChange={(e) => {
data[k] = Number(e.target.value);
setData();
}}
/>
)
}else if (typeof(schema[k]) === "string"){
return (
<TextField
label={format(k)}
defaultValue={data[k]}
type="text"
InputLabelProps={{
shrink: true,
}}
onChange={(e) => {
data[k] = String(e.target.value);
console.log(data);
setData();
}}
/>
)
}else if (typeof(schema[k]) === "boolean"){
return (
<FormControlLabel
control={
<Checkbox
checked={data[k]}
name={name + "-" + k + "-checkbox"}
color="primary"
/>
}
label={format(k)}
onChange={() => {
data[k] = !data[k];
setData();
}}
/>
)
}else{
return (<div style={{display: "none"}}></div>);
}
}else{
return (<div style={{display: "none"}}></div>);
}
})
}
</div>
</AccordionDetails>
</Accordion>
</div>
);
}
export default GenericForm;
view raw GenericForm.js hosted with ❤ by GitHub

The schema is used as the single source of truth for the rendering. This becomes particularly useful when dealing with arrays (I omitted that part here.) Here setData is the function described in step 3 above. The functions display and format come in handy to decide whether to show and how to show an element respectively. The function keyToFieldName converts camel case like thisIsAField to a space delimited phrase like This Is A Field.

Notice how for the leaf nodes, the value in schema defines the type of the Input field to be used and also its default value.

Here is how you use the component:

import React from "react";
import { cloneDeep } from 'lodash';
import { connect } from 'react-redux';
import { defaultCity } from '../reducers/cityReducer';
import { saveCity } from '../actions/cityActions';
import GenericForm from './GenericForm';
function CustomForm({ city, dispatch}){
const classes = useStyles();
let data = cloneDeep(city);
const setData = () => {
dispatch(saveCity(cloneDeep(data)));
}
return (
<div>
<GenericForm schema={defaultCity} data={data} name="City Parameters" display={k => true} setData={setData} />
<div className={classes.btnRoot}>
<Button variant="contained" color="primary" onClick={() => {setData()}}>Save and Continue</Button>
</div>
</div>
)
}
const mapStateToProps = (state, props) => {
return {
...props,
city: state.city
}
};
export default connect(mapStateToProps)(CustomForm);
view raw CustomForm.js hosted with ❤ by GitHub

Notice how we are using the cloneDeep function from lodash library to perform the deep copy.

dispatch and mapStateToProps are usual Redux jargon. city is the actual data field on which the form is built. saveCity is a function that wraps the data to be used to set the city object inside a Redux action. defaultCity is the default schema (obvious!).

Result

Final Result

This is what it kinda looks like. I think you can guess how city data structure looked like.

I am new to Redux. Let me know in the comments if you found anything wrong here.