Creating Flexible, Configuration-Driven Forms with React, React-Bootstrap and Redux-Form
This repository demonstrates tooling that we have developed at Swift Navigation for building internal data entry applications for our network team. These applications consist of a large number of forms whose formatting depends heavily on a data model that we maintain in the backend. Due to the relative newness and rapid growth of the autonomous vehicle industry, the needs of our customers change rather frequently. The complexity of our network and the needs of our field technicians grow as a direct result. To adequately model and remotely manage all of the hardware in our contiguous U.S. (CONUS) network, we are often tweaking our data model and the capabilities of our API. A discovery that we made along the way was that frontend form updates were often slow and painful, even for small changes to the data model and served as blockers to releasing new features. We were a small engineering team that did not have the bandwidth to spend hours rewriting and debugging a slew of UI forms several times a month. We dreamed of a world in which trivial changes to our database schema, such as adding a new field, would result in equally simple changes across the stack. We longed to create beautiful, stateful and complex forms in the frontend that could be easily maintained and reworked. We wanted misconfigured JSX forms to give us meaningful error messages during development rather than failing silently. We believed that our many JS frameworks (React, Redux, React-Bootstrap and Redux Form) could work together in harmony without creating a giant monstrosity of a codebase that is impossible to read or understand. The sample project discussed here is what we created as a result.
Overview
The purpose of this guide and its associated repository is not to provide and document a feature-complete form-building library, but to explain an efficient and powerful method for building form-heavy applications that has worked well for the cloud engineering team at Swift. It is our hope that this tutorial will give you valuable starter code and enough knowledge to build similar applications that are tailored to your own needs. Please note that this tutorial assumes some familiarity with React, React-Bootstrap, Redux and Redux-Form. You are encouraged to check out our sample repository and run the project locally as you read through this article.
General Approach to Form Building and State Management
If you set up and run this project, you will see a single-page app with an accordion style panel form. Click the panel headers to watch the form in action!
This particular form is housed in a React component called StationForm. If you open up /src/components/StationForm.jsx
, you may be surprised to find only a few lines of plain javascript and no JSX code whatsoever:
import { reduxForm } from 'redux-form';
import { stationForm, stationType } from '../config';
import { ReduxPanelForm } from './FormTemplates';
const StationForm = reduxForm({
form: 'stationForm',
formConfig: stationForm,
initialValues: stationType.Default,
onSubmit: values => console.log(values)
})(ReduxPanelForm);
export default StationForm;
When we build individual forms, we are essentially dividing the work into three separate categories:
- Managing form state (primarily a Redux problem)
- Overall look and feel (primarily a Bootstrap problem)
- Input-level configuration (should be driven by a configuration file)
Wherever possible, we build abstractions to maintain the independence of the source code in each of these three categories. The code snippet above illustrates this principle. Here, we are connecting a form to our state management tools (problem 1), so styling and input-level configuration have been abstracted out of this file. Form styles are condensed into the component ReduxPanelForm
, where we have defined a Bootstrap form template. Form configuration is stored in the stationForm
object, which is passed by reduxForm
to ReduxPanelForm
as the prop formConfig
. You may recognize the other props form
, initialValues
, and onSubmit
from the Redux-Form docs, but the formConfig
prop is our own. By default, the reduxForm
wrapper passes any unrecognized props through to the child component. You may have noticed that we are also configuring the form's initial values using the stationType
object, but we will leave the explanation of that object for a later section.
This method for constructing forms becomes valuable when we have a variety of form configuration arrays and Bootstrap templates that we want to mix and match quickly and reuse often. For example, if we want to make a second form that differs from the one above only in its submit behavior, we can do so by simply duplicating the code above and changing the form
and onSubmit
fields. Perhaps we want only to change the look of the form. We can simply wrap our reduxForm
call around a new Bootstrap template. In just a few seconds and a few lines of code, we have another beautiful, stateful form!
Form Config Files
The form config array we are passing to Redux-Form in the section above is really quite simple. Let's take a look at /src/config/stationForm
:
import stationType from './stationType';
const section = (title, fields) => ({ fields, title });
const stationForm = [
section('General', [
stationType.name,
stationType.notes
]),
section('Status', [
stationType.active,
stationType.condition
]),
section('Location', [
stationType.country,
stationType.state,
stationType.latitude,
stationType.altitude,
stationType.longitude
])
];
export default stationForm;
Form configs are just arrays of section objects, where each section has a title and an array of field objects. Again, we will reserve our discussion of what these input fields look like for a later section. This file is where we can change field ordering and grouping, as well as the section names. If you are running this repo locally in development mode, go ahead and try some things out. Swap the order of two fields or change a section name. Save the file and see what happens.
Bootstrap Templates
So far, we have still not come across any of the familiar JSX code that we are used to seeing in React. In this section, we will finally begin to build the components that will appear on screen. Let's have a look at our panel form template in /src/components/FormTemplates/ReduxPanelForm.jsx
. Looking at the list of expected props for this component, we see the formConfig
array that we discussed in the previous section, as well as a number of other props passed in by the reduxForm
wrapper:
static propTypes = {
form: PropTypes.string.isRequired,
formConfig: PropTypes.arrayOf(PropTypes.object).isRequired,
handleSubmit: PropTypes.func.isRequired,
invalid: PropTypes.bool.isRequired,
submitFailed: PropTypes.bool.isRequired,
submitSucceeded: PropTypes.bool.isRequired
}
The list above contains only the props needed by this particular accordion-style form, but for an exhaustive list of the fields made available by Redux Form, check out these docs. Shifting our focus down to the render method, we see that our template renders a Form
component containing a Panel
hierarchy. Rather than focusing on the details of accordion panel groups, which can be found in React Bootstrap package documentation, let's see how our component is interacting with the custom formConfig
prop. Within the PanelGroup
component, we observe the following:
{this.props.formConfig.map((section, i) => (
<Panel key={section.title} eventKey={i}>
<Panel.Heading>
<Panel.Title toggle>
{section.title}
</Panel.Title>
</Panel.Heading>
<Panel.Collapse>
<Panel.Body>
{section.fields.map(field => (
<Field
component={FieldMapper}
key={field.name}
{...field}
/>
))}
</Panel.Body>
</Panel.Collapse>
</Panel>
))}
The form template is pretty straightforward: every section in the formConfig
array gets mapped to a Panel
, and then within the panel body, we are mapping each section field to an input component. This input mapping logic is one point at which form development can easily become messy, especially if we are using Redux Form for state management. We mentioned in the first section of this tutorial that we are trying to keep the Redux and Boostrap pieces of this project independent from each other wherever possible. Unfortunately, we have arrived at a point where some crossover between the two packages is necessary. One of the challenges with form state management is that it is not enough to simply wrap the entire form with reduxForm
. For proper state updates, we need to explicitly nest each Bootstrap input group within the Field
wrapper component. If our inputs also have labels and other styling elements grouped with them, we may have to pass an entire FormGroup
component to a Field
and then define custom interactions between the wrapper component and its children. Given that not every input type or group is going to have the same components or behaviors, it is easy to see how our form template could quickly become bloated and perhaps require a lengthy switch statement or worse.
The code you see above, however, abstracts out all of this complexity. The least common denominator between all inputs, so to speak, is this Field
component. Therefore, we delegate Field
as the lowest level component in our form template and then abstract out all of the remaining input selection logic into a custom FieldMapper
component, which will be discussed in the next section.
Field Selection and Input Components
As alluded to previously, the FieldMapper
handles input field selection in our forms. If we open up /src/components/FormFields/FieldMapper.jsx
, we can see that this component is nothing more than a glorified switch statement:
import React from 'react';
import { allowedTypes } from '../../helpers';
import { CheckboxField, SelectField, TextareaField, TextField } from './index'
const FieldMapper = (props) => {
switch (props.type) {
case allowedTypes.checkbox:
return <CheckboxField {...props} />;
case allowedTypes.select:
return <SelectField {...props} />;
case allowedTypes.text:
return <TextField {...props} />;
case allowedTypes.textarea:
return <TextareaField {...props} />;
default:
return null;
}
};
The mapper checks the value of props.type
(an attribute of the field
object, which will be discussed in a later section) and renders the appropriate component. In the case that an unrecognized type is provided, the proxy renders nothing. All props are passed down to the child input components.
Let's take a look at one of these input fields in /src/components/FormFields/TextField.jsx
:
const TextField = ({
input,
label,
meta: { touched, error, warning },
placeholder
}) => (
<FormGroup validationState={
(touched && ((error && 'error') || (warning && 'warning')))
|| null}
>
<ControlLabel htmlFor={input.name}>{label || input.name}</ControlLabel>
<FormControl {...input} placeholder={placeholder} type="text" />
</FormGroup>
);
Let's remind ourselves where each of the above props is coming from. The Field
component creates its own props, of which we are using the input
and meta
objects. input
contains all the data and functions necessary for the input field to interact properly with the Redux state, so it is very important that this prop is passed directly into the underlying FormControl
component in its entirety. The meta
object is less crucial, but it provides helpful information that can be used for styling purposes. Here, we are using this data to set the validationState
prop for FormGroup
, which will cause React Bootstrap to perform some nice dynamic styling on the field for us.
All props other than input
and meta
are being passed in from the field
configuration object. In this case, we are making use of label
and placeholder
.
There are other input components defined in this project, and they all follow the same basic model. We will not walk through them in detail here but will leave that as an exercise for the reader.
Type Definition
All the previous sections have walked us through the various pieces of our form, but we are finally ready to demystify field-level configuration, which we have been treating more or less as a black box up to this point. Open up /src/config/stationType.js
and you will see the following:
import { allowedTypes, validate, makeType } from '../helpers';
const stationType = makeType('station');
...
We can see that stationType
is being initialized with a helper function called makeType
, but it is really nothing more than an object. All that makeType
does is wrap that object in a proxy which will set a name
property for each of the fields that we define. It also performs a number of other nice debugging functions for us, but we will cover these in a later section. For now, thinking of stationType as a plain object will serve our purposes. Continuing down the page, we see our first field definition:
// DEFINE STATION FIELDS BELOW ***************************************************************************
stationType.active = {
default: true,
label: 'Active?',
type: allowedTypes.checkbox
};
...
Here, we are defining a field called active
. Note that what we choose to call this field is important, as this value will be mapped to the name
prop by our proxy and will be used to identify this field in our Redux state. At Swift, we sync these field names and the overall structure of the data with that of our database schemas so that we can submit form data directly to API clients with little to no modification. In the example above, we are implying that we have a station table in our database, and each entry has a Boolean attribute, active
.
For the field above, we are defining just three properties:
default
: a default value for the fieldlabel
: a human-readable label for the form inputtype
: the input component type for this field (used byFieldMapper
)
By design, these three properties are required for every field definition. If you look at some of the other fields, you will notice that we can make use of other configurable properties, depending on what input type we are using:
...
stationType.country = {
default: 'USA',
label: 'Country',
options: ['AUS', 'USA'],
type: allowedTypes.select
};
stationType.latitude = {
default: null,
label: 'Latitude',
placeholder: 'degrees N of equator',
type: allowedTypes.text,
validate: [validate.coords]
};
...
The select field above has an array of options for the dropdown window, and the text field has a placeholder and an array of validation functions. In general, any property that is consumed by the input component as a prop can be configured here. All changes made here will also be instantly applied across all relevant forms on the website. This gives us the ability to easily keep frontend forms up-to-date with our backend model.
The Type Proxy
At this point, we have explained nearly all of the functionality of our form tools. However, you may recall from the first section that we were passing in stationType.Default
as a set of initial state values for Redux Form. Where is this coming from?
Have a look at src/helpers/type.js
and scroll down to the bottom, where the function makeType
is defined:
export const makeType = name => new Proxy({}, {
get: (obj, property) => {
...
},
set: (obj, property, value) => {
...
}
});
This function takes a type name and returns an object proxy. The beauty of proxies is that they function exactly like objects, except we have the ability to intercept actions that update or access attributes in the object, and make adjustments or throw errors as necessary. The get
function above, defines the behavior that we would like to see any time a property of this object is accessed. This can be incredibly powerful and we will discuss why in a moment. The set
function behaves similarly, except it is fired any time the object is mutated in some way. In our case, this allows us to validate field configuration objects as they are set to make sure that specific rules are met:
set: (obj, property, value) => {
if (process.mode === 'development' || process.mode === 'test') {
checkConfigObj(obj, property, value, name);
}
Reflect.set(obj, property, {
...value,
name: property
});
return true;
}
In the function above, we are first checking that we are running in either test
or development
mode. If this is the case, we call a function that runs a series of validation tests. We will not cover the full list of validation tests here, but one particularly important test will fail if we attempt to set any key more than once. This means an error will be raised if any component attempts to mutate the object during run-time (a very helpful debugging feature). Once all tests pass, we then set the object property and add to it the name
key, which will be used by forms to identify the field.
Adding get
functionality to our object proxy also turns out to be very useful:
get: (obj, property) => {
if (property === 'Empty') {
return _.mapValues(obj, () => null);
}
if (property === 'Default') {
return _.mapValues(obj, field => field.default);
}
if ((process.mode === 'development' || process.mode === 'test') && obj[property] === undefined) {
throw new Error(`Attempting to access undefined field '${property}' in config ${name}`);
}
return obj[property];
}
Here you can see that we are creating two reserved keywords Empty
and Default
. Empty
returns a copy of the current object with all values mapped to null, and Default
returns a copy of the current object with all values mapped to their configured defaults. These are very useful tools for providing initial state to Redux Form. If the property being accessed is not Empty
or Default
, the proxy checks if the value has been defined and throws an error if it has not (in development and test modes). Typically, JS applications fail silently when components access undefined object properties. In our case, this could lead to bizarre form behavior that is hard to debug. With this test though, we now have the ability to tell when our components are attempting to access non-existent fields. This debugging tool has aided our development process countless times.
Final Remarks
Since this sample repository was created, we have built out some of our tools to allow for even more complex forms, such as those with nested input groups. The framework described here readily extends to this level of complexity with a few logic additions and has served us very well. We hope to follow up with another post and updates to the repository. In the meantime, happy developing. :)