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:
- Break our large component down into smaller components.
- Implement Pure Components to better control re-renders.
- 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 Input
s.
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.
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.
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 toReact.Component
. The difference between them is thatReact.Component
doesn’t implementshouldComponentUpdate()
, butReact.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.
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
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.
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.