Large Dynamically Generated Forms in React — Part 2

In part 1, we looked at ways to create dynamically generated forms. In part 2 we are going to walk through ways of optimizing our form by using the new React Profiler and React Context. While in this post we are focused on how to optimize our form, these steps can be used to better optimize any complex component tree.


In our last section, we created a simple “all-in-one” form where everything was being handled by a single component. The code is messy and complex to reason about. Additionally, every keystroke results in the entire component re-rendering. In this specific case, the user may not notice the cost of the repeated re-renders but if the form contained more complex inputs or a more complex layout, this can become noticeable.

We are going to try to address these issues with the following steps:

  1. Break our large component down into smaller components.
  2. Implement Pure Components to better control re-renders.
  3. Lift our data out into context so it doesn’t need to be passed all the way down the tree.

Step 1 — Break into smaller components

Independent of our goal of optimizing our forms, it is good practice to separate components into simple, single-purpose components. While not specifically mentioned in the React docs, there are many posts around the community talking about the benefits of SOLID design principles. In this point, we aren’t going to address SOLID design but it is worth knowing it exists. In our case, there are a few lines that can be drawn to separate different responsibilities of our form. Let’s walk through what each of these looks like and discuss their responsibilities.

For each of these components, I will link to the Github repo so you can view the full component code.

Form

Our form is the top level container. The forms responsibilities are to wrap all the albums, hold the form state, and submit the data to be saved.

class Form extends Component {
constructor(props) {
super(props)
this.handleSetState = this.handleSetState.bind(this)
//Setup initial State for controlled inputs...
}
handleSetState(object){
this.setState(object)
}
handleSubmit = (e) => {
e.preventDefault()
console.log('Submitting Form! Form data:', this.state)
}
render() {
return (
<form style={styles.form} onSubmit={this.handleSubmit}>
<h1 style={styles.formTitle}>Dynamic Form Organized</h1>
{albums.map((album) => {
const { albumId, albumName } = album
return (
<div key={albumId} style={styles.albumWrapper}>
<Title albumName={albumName} />
<Body
albumId={albumId}
state={this.state}
setState={this.handleSetState}
/>
</div>
)
})}
<button type="submit">Submit</button>
</form>
)
}
}

One big thing to note here, is that the form is still holding all the input data in state and thus we need to pass down state and the ability to setState to Body where the inputs are held.

Title

Our title component is simple, it just needs to display the albumName.

class Title extends Component {
render() {
const { albumName } = this.props
return (
<div style={styles.titleWrapper}>
<h3 style={styles.title}>{albumName}</h3>
</div>
)
}
}

Body

The Body component holds all of our inputs for each album. It only really acts as a UI container and passes down props to the Inputs.

class Body extends Component {
render() {
const { albumId, state, setState } = this.props
return (
<div style={styles.body}>
{fields.map((field) => {
const { fieldId, fieldName } = field
          return (
<div style={styles.formRow} key={fieldId}>
<label htmlFor={fieldId} style={styles.rowLabel}>
{fieldName}
</label>
<Input
{...field}
albumId={albumId}
state={state}
setState={setState}
/>
</div>
)
})}
</div>
)
}
}

Input

Our Input component takes in each field and creates a controlled component. For simplicity we can assume each input is just a simple text input but in the full component you can see how I implemented more input types.

class Input extends Component {
render() {
const {
fieldId,
albumId,
state,
setState
} = this.props
const stateKey = `${albumId}_${fieldId}`

return (
<input
type="text"
id={fieldId}
style={styles.textInput}
onChange={(event) =>
setState({
[stateKey]: event.target.value,
})
}
value={state[stateKey]}
/>
)
}
}

Here again we are using the albumId and fieldId to store and look up each input’s data.

Great! We have now split our dynamic form up into clearer components. Our component hierarchy now looks like:

Form
|-> Title
|-> Body
|-> Inputs

We are finally at the stage where we can start to see how rendering works with React Profiler. Let’s look at a before and after shot.

React Profiler — Before Creating Separate Components

Before when there was only one large component we can see that we get a flat flame graph. The entire single component is re-rendering each keystroke.

React Profiler — After Creating Separate Components

Now that looks better. We can see that we have a tree of components and can see how long it takes each component to re-render. However, we can see that with each keystroke is causing our entire tree to re-render. Components like Title and Body are having to re-render even though they aren’t changing at all. Title is still being provided the same title each time and Body is really just a styled wrapper for the inputs. Let’s looks at what we can do to reduce these re-renders.

Step 2 — Implement PureComponents

You will notice in all of our components we extend React’sComponent class. React also offers another class to extend from called PureComponent. The React docs state:

React.PureComponent is similar to React.Component. The difference between them is that React.Component doesn’t implement shouldComponentUpdate(), but React.PureComponent implements it with a shallow prop and state comparison.

In most cases doing a shallow comparison should suffice for determining if a component should re-render. For out Title component this is perfect. Let’s implement PureComponent on all of our new components and compare what the profiler looks like.

React Profiler — After Implementing PureComponent

Progress! We can see left of each Body block is a grey block. That is the Title component not re-rendering. You will notice that Form Body and each Input is still updating because the state and setState props are still being passed down. In step 3 we will try to address the extra re-renders in Body by lifting the state data out into Context.

Step 3 — Lift Data Into Context

As of React 16.3, Context has gotten an officially supported API. Since that release, the community has continued adopting it as a way to stop threading props all the way down the component tree. The React docs state:

In a typical React application, data is passed top-down (parent to child) via props, but this can be cumbersome for certain types of props (e.g. locale preference, UI theme) that are required by many components within an application. Context provides a way to share values like these between components without having to explicitly pass a prop through every level of the tree.

Our FormDataContext is going to simply provide the same functionality that the Form component does with respect to holding and managing state. Let’s look at what this looks like:

export const FormDataContext = React.createContext()
export class FormDataProvider extends PureComponent {
constructor(props) {
super(props)
    //Setup initial State
const formData = {}
for (let i = 0; i < albums.length; i++) {
for (let j = 0; j < fields.length; j++) {
formData[`${albums[i].albumId}_${fields[j].fieldId}`] = ''
}
}
    this.state = { formData, setState: this.handleSetState }
}
handleSetState = (object) => {
const { formData } = this.state
this.setState({ formData: { ...formData, ...object } })
}
  render() {
return (
<FormDataContext.Provider value={this.state}>
{this.props.children}
</FormDataContext.Provider>
)
}
}

Our context state contains the formData and a function to update that data. From there, we need to review each component and implement the same functionality with our new context.

App

We start by wrapping our Form with our Context provider.

class DynamicFormOrganizedWithContext extends Component {
render() {
return (
<FormDataProvider>
<Form />
</FormDataProvider>
)
}
}

Form

In our form, we can remove all the of the state management and the need to pass down state and setState as we did before. However, in order to still have the ability to submit we need access to the context’s state. Prior to React 16.6, we would need to wrap our Form component in a context consumer and pass the props down to be accessible throughout the class. Now, we can use static contextType which keeps our code cleaner and reduces nesting.

class Form extends PureComponent {
static contextType = FormDataContext
constructor(props) {
super(props)
this.handleSubmit = this.handleSubmit.bind(this)
}
handleSubmit(e) {
e.preventDefault()
console.log(
'Submitting Form! Form data:',
this.context.formData
)
}
render() {
return (
<form style={styles.form} onSubmit={this.handleSubmit}>
<h1 style={styles.formTitle}>
Dynamic Form Organized With Context
</h1>
{albums.map((album) => {
const { albumId, albumName } = album
return (
<div key={albumId} style={styles.albumWrapper}>
<Title albumName={albumName} />
<Body albumId={albumId} />
</div>
)
})}
<button type="submit">Submit</button>
</form>
)
}
}

Title

Nothing to change here.

Body

Our Body component simplifies to simply looping over our field data and generating layout UI.

class Body extends PureComponent {
render() {
const { albumId } = this.props
return (
<div style={styles.body}>
{fields.map((field) => {
const { fieldId, fieldName } = field
          return (
<div style={styles.formRow} key={fieldId}>
<label htmlFor={fieldId} style={styles.rowLabel}>
{fieldName}
</label>
<Input {...field} albumId={albumId} />
</div>
)
})}
</div>
)
}
}

Input

Once again we use the static contextType to access our context data in our Input component. The only real difference here is where we are getting our state (now called formData) and setState functions from.

class Input extends PureComponent {
static contextType = FormDataContext
render() {
const { fieldId, fieldType, fieldOptions, albumId } = this.props
const { formData, setState } = this.context
const stateKey = `${albumId}_${fieldId}`
return (
<input
type="text"
id={fieldId}
style={styles.textInput}
onChange={(event) =>
setState({
[stateKey]: event.target.value,
})
}
value={formData[stateKey]}
/>
)
}
}

Wonderful, let’s see how these changes affect our re-rendering with the profiler.

React Profiler — After Lifting Data into Context

Awesome! Here you can see a few things. First, our new context data layer, FormDataProvider, at the top. Second, that each keystroke isn’t causing Body to re-render. Of course, Form and each Input is being asked to re-render because the context state is updating.

At this point, it would be cool to try to implement a way to stop all the extra Input re-renders. However, to the best of my knowledge at this time, shouldComponentUpdate only looks at props and state. Maybe this will change in the future. So this is as far as we can go for now.


With that, we have step through some ways you can look at performance in your complex components. While in this example there may not have been dramatic UX improvements, it was a great exercise in the user of React Profiler, Context, and PureComponents.

In part 3 we are going to work through what part 1 and 2 might look like in React Hooks.


Source code for this entire series can be downloaded here.