Form building with recursion
While working on a current project, I was tasked with the following, build a form that can render an infinite number of inputs and an enumerable type of inputs. You should have the freedom to stack these however you like.
I chose to write the template for how the form is built separate from what the form will be made up of. The template builds the same way everytime and expects a configuration file.
The configuration file looks like this,
Basic Configuration
export default [ [ { key: 'quantity', // used by react to identity the element type: 'textField', // enumerable to render a specific input display: 'Number:', // the label for the input inputProps: { // props that get drilled down to the wrapped component variant: 'outlined', style: { width: '200px', }, placeholder: 'number', type: 'number', }, labelProps: { // props that get drilled down to the wrapped component's label style: { color: 'blue' } }, }, ] ];
Result -
As outlined above, the configuration file exports an array by default. Each index in that array represents a new row for the form. But what if I want to stack more than one input in a single row?
Extended Configuration
[ { ... type: 'textField', // enumerable to render a specific input ... }, { ... type: 'textField', // enumerable to render a specific input, ... }, ],
Result -
NBD! Simply stack objects one after another. But what if the designer gets adventurous and wireframes something that your nested for loop cant handle?
Advanced Configuration
[ { ... type: 'textField', // enumerable to render a specific input placeholder: "Test1", ... }, [ { ... type: 'textField', // enumerable to render a specific input, placeholder: "Test2", ... }, [ { ... type: 'textField', // enumerable to render a specific input, placeholder: "Test3", ... }, [ { ... type: 'textField', // enumerable to render a specific input, placeholder: "Test4", ... }, [ { ... type: 'textField', // enumerable to render a specific input, placeholder: "Test5", ... }, { ... type: 'textField', // enumerable to render a specific input, placeholder: "Test5", ... }, { ... type: 'textField', // enumerable to render a specific input, placeholder: "Test5", ... }, { ... type: 'textField', // enumerable to render a specific input, placeholder: "Test5", ... }, ], ], ] ], ],
Result -
Here we have a nightmare of a form, but it's still feasable. The designer wanted 2 columns of inputs. On the right hand side, she requested two inputs. For the input underneath, we had to have 2 more inputs next to it, in a column. Lastly, we were specifically instructed to make sure the input on the bottom of that column has 4 inputs in a row.
If you compared the snippet to the output, you can see that anytime you want to swtich the way the inputs are stacked, you wrap it in a fresh array. Everytime you do this it switches the direction from row
to column
and vice versa.
The Code
Now that you have an idea on what the function expects, we can see the work in action.
const buildForm = (data, index, jsx, tmp, direction, depth) => { if (data[index] === undefined) { if (depth === 0) return jsx; return tmp; } else if (Array.isArray(data[index])) { const items = buildForm(data[index], 0, jsx, [], switchDirection(direction), depth + 1); if (direction === "row") { if (depth === 0) { jsx.push(<Row key={`${index}-${direction}`}>{[...tmp, ...items]}</Row>); tmp = []; } else tmp.push(<Row key={`${index}-${direction}`}>{items}</Row>); } else { if (depth === 0) { jsx.push(<Column key={`${index}-${direction}`}>{[...tmp, ...items]}</Column>); tmp = []; } else tmp.push(<Column key={`${index}-${direction}`}>{items}</Column>); } return buildForm(data, index + 1, jsx, tmp, direction, depth); } const newElement = item(data[index]); tmp.push(newElement); return buildForm(data, index + 1, jsx, tmp, direction, depth); }
Let's look at this piece by piece.
The Parameters
The function accepts 6 parameters.
const buildForm = (data, index, jsx, tmp, direction, depth) => {
data
is the configuration array.
index
is the current index within data.
jsx
is an array of jsx elements that gets built throughout the process. It is what gets ultimately returned.
tmp
is an array of jsx elements that gets held so that the order of elements does not get messed up.
direction
is which way the elements should be rendered.
depth
is a counter that adds 1 everytime an array is found so it knows when its at the root level.
The Base Case
if (data[index] === undefined) { if (depth === 0) return jsx; return tmp; }
This case is checking to see if the current index within data is undefined
. If it is, we do 1 of 2 things. We either return the jsx
array or the tmp
array. When depth
is at the root level (typically 0), we know we are done looking at every item in the array and can finally return jsx
. Every other situation, we return tmp
so that we can continue to build the form.
Handling Array Logic
else if (Array.isArray(data[index])) {
There are two data types that data[index]
can be, an object or an array. This else if
block is checking if it's an array.
const items = renderItems(data[index], 0, jsx, [], switchDirection(direction), depth + 1);
The first thing we do to the children arrays is set a variable equal to the return value of calling the function recursively.
data[index]
is our working array, jsx
is our master array containing all of our elements that we're building and depth + 1
is a variable we keep track of to know whether or not we're done traversing our data.
switchDirection(direction)
is a function that accepts a string that's either row
or column
and returns the opposite value. As explained in the base case, items
will be an array.
if (direction === "row") { if (depth === 0) { jsx.push(<Row key={`${index}-${direction}`}>{[...tmp, ...items]}</Row>); tmp = []; } else tmp.push(<Row key={`${index}-${direction}`}>{items}</Row>); } else { if (depth === 0) { jsx.push(<Column key={`${index}-${direction}`}>{[...tmp, ...items]}</Column>); tmp = []; } else tmp.push(<Column key={`${index}-${direction}`}>{items}</Column>); }
After items
is set, we push it to an array in either a row or a column. The JSX
tags for row and column are styled components that contain the CSS property flex-direction
of their respective value.
There is a check for each direction to see if the depth is equal to 0 or not. When the depth is equal to 0, we can assume that it's safe to record this row in the master array jsx
. When depth isn't equal to 0, we can assume that there might be more elements to draw, so we keep track of it in the tmp
array. Agnostic of the depth, we wrap the items returned from the recursive call and the elements within the tmp
array and spread them within their appropriate direction.
return renderItems(data, index + 1, jsx, tmp, direction, depth); }
Now we recursively call the function again and return its value. We keep everything the same except for index. We increment it by 1 so that we can look at the adjacent element, if it exists.
This Is Where The Magic Happens
const newElement = item(data[index]); tmp.push(newElement); return renderItems(data, index + 1, jsx, tmp, direction, depth); }
Sorry, no magic. We set the variable newElement
equal to be the return value of an item
function that we've wrote elsewhere. The return value is an input element based on the configuration passed by the user. Specifically referencing the type
field they passed.
We take this value, push it into the tmp
array and return it recursively, in the exact same fashion we did above.
That's it! If this reaches you and helps, let me know!