~/ Recursive React Forms

I was recently working on a SOAP client user interface and needed the ability to nest inputs to an arbitrary depth and edit the data inline.

Demo

Given an object of unknown depth:

{
"age": 33,
"name": {
"first": "Jesse",
"last": "Shawl"
},
"interest": {
"javascript": {
"for": "sure"
}
}
}

Display a form that visually matches the data structure and allows users to edit the data.

See the Pen Recursive React Form by Jesse Shawl (@jshawl) on CodePen.

Why Use Recursion?

I probably could have assumed that any data structures would not be more than 3 or so levels deep and kept track of the level, but I was curious about handling an infinitely deep data structure.

How does it work?

I started out with the simple case:

{
"age": 33
}

and focused on a simple component to manage state changes:

function TableForm({data}){
return (
<table>
<thead>
<tr>
<td>key</td>
<td>value</td>
</tr>
</thead>
<tbody>
{Object.keys(data).map(key => {
return <tr>
<td>{key}</td>
<td>{data[key]}</td>
</tr>
})}
</tbody>
</table>
)
}

Making the data structure a little more complex will error out, because data[key] is an object and React doesn't allow objects as children. Enter the recursive case.

function TableForm({data}){
return (
<table>
<thead>
<tr>
<td>key</td>
<td>value</td>
</tr>
</thead>
<tbody>
{Object.keys(data).map(key => {
return <tr>
<td>{key}</td>
<td>{
typeof data[key] === "object" ?
<TableForm data={data[key]} /> : {data[key]}
}</td>
</tr>
})}
</tbody>
</table>
)
}

This will render the nested tables no problem. Things became complicated when I tried to handle change events recursively. Let's dig in.

Recursive Change Handlers

I decided to manage state all the way up at the <App /> level, which told me I'd need to pass an onChange prop to TableForm.

We also need some inputs. Here's an abbreviated set of components:

function TableForm({data, onChange}){
// ...
{
typeof data[key] === "object" ?
<TableForm data={data[key]} onChange={onChange}/> :
<input value={data[key]} onChange={onChange}/>
}
// ...
}

function App(){
return (
<TableForm onChange={d => console.log('updated data is:', d)}>
)
}

This will work great for the single level data structure. e.g. modify the age input and the object that comes out of the top level onChange will be {age: 33}.

The problem is that editing children objects will bubble all the way up to the top level onChange.

e.g. given an object like:

{
"name": {
"first": "Jesse",
"last": "Shawl"
}
}

onChange will only ever send back {first: "Jesse"} or {last: "Shawl"}.

I wanted to make sure the entire data structure came back out, so:

function TableForm({data, onChange}){
// ...
{
typeof data[key] === "object" ?
<TableForm data={data[key]} onChange={d => onChange(...data, [key]: d)}/> :
<input value={data[key]} onChange={e => onChange(...data, {[key]: e.target.value})}/>
}
// ...
}

Now editing the first input will trigger the top level onChange to send back the entire object:

{
"name": {
"first": "Jesse",
"last": "Shawl"
}
}

Pretty cool! You can scroll up to play around with the demo or fork it on codepen.

I'm super tempted to make a slick Soap client UI now that I have the basic code to edit its data structure. We'll see what comes of it.


~/ Posted by Jesse Shawl on 2022-12-08