When it comes to form solutions in React, we have a few choices. Different solutions solve different problems but there is one that stands out almost in every aspect. The name is React Final Form – based upon the framework agnostic form solution called Final Form.
My favorite feature of React Final Form (RFF) is the ability to create nested and array of fields. If you ask me to choose the next one then it’s definitely the asynchronous validation.
I have been using RFF since it came into existence and many times I faced a few difficulties to set the things right. One such difficulty was the performance of huge forms:
- The text inputs would lag quite a bit.
- Conditional UI updates were not smooth at all.
- Switching to Tabs in UI took quite a bit of time and much more.
The reason for all this was the continuous state changes inside RFF which caused tons of re-renders. Well, we really can’t do anything about those state changes – they are bound to happen and is actually what makes RFF a super power. But, there is something we can do in our application which will drastically improve performance.
Solution 1: Final Form subscription
RFF comes with a pretty decent solution for performance improvements, i.e “subscription”. It no doubt is a great solution that’s pretty handy for most of the situations. But I faced many issues in the cases where the form was dynamically rendered and I needed this dynamic form state to be available. For example, I needed to display the current value of a field to conditionally display anther field. It was a situation where <FormSpy />
would make the code look ugly – I am a functional programming guy and prefer to use useForm()
π
Solution 2: React.memo()
When you pass component
prop to Form
component from 'react-final-form'
, that component receives the whole RFF state as props and thus is re-rendered on every state change. It not only re-renders that component but also all its children even when they should not re-render. So, how do you improve the performance in such cases? It’s React.Memo()
. I know you want to see the code, so here it is:
// MyForm.tsx
const MyForm: React.FC = () => {
return (
<Form
component={FormRenderer}
initialValues={initialValues}
onSubmit={submitForm}
/>
);
};
You can see that we did not pass subscription
prop.
Now our FormRenderer
component has the responsibility to render our form based on the props it receives from RFF.
// FormRenderer.tsx
const FormRenderer: React.FC<FormRenderProps> = ({ handleSubmit }) => {
return (
<form onSubmit={handleSubmit}>
{/* The fields here */}
</form>
);
};
export default FormRenderer;
This component will re-render even if you hover over or focus on your fields. π€·π»ββοΈ
Now lets bring in React.memo
– a HOC to boost performance. It accepts a second argument propsAreEqual
which is simply a function to compare previous props with new props to decide whether they are really different. So, instead ofexport default FormRenderer;
above, we will do something like this:
export default React.memo(FormRenderer, propsAreEqual);
Now the question is, how does our propsAreEqual
look like? Here it is:
// TS >= 3.8
import type { AnyObject } from 'final-form';
import type { FormRenderProps } from 'react-final-form';
const propsAreEqual = <FormData = AnyObject>(
prevProps: FormRenderProps<FormData>,
nextProps: FormRenderProps<FormData>
): boolean => {
const prevValue = JSON.stringify(prevProps.form?.getState());
const nextValue = JSON.stringify(nextProps.form?.getState());
return prevValue === nextValue;
};
If you aren’t familiar with TypeScript, here is the same function in pure JavaScript:
// ES 2020
const propsAreEqual = (prevProps, nextProps) => {
const prevValue = JSON.stringify(prevProps.form?.getState());
const nextValue = JSON.stringify(nextProps.form?.getState());
return prevValue === nextValue;
};
So, what does this function do? If you don’t pass that function to React.memo
, React will do a shallow comparison – Object.is()
between previous and next props which doesn’t serve any purpose in our case because the props here are complex objects and Object.is()
will always return false
which means that previous and next props are different and thus forces the component to re-render. That’s the accepted behavior because React doesn’t know we are inside RFF. π
So we need to tell React, “Dude, listen! We have objects in props, let me tell you whether they are actually different or not.” That’s exactly what our propsAreEqual
does.
It converts RFF state from previous and next props to a string and then compares those string values. Those string values will be different if and only if the state has actually changed.
Complete example
const propsAreEqual = (prevProps, nextProps) => {
const prevValue = JSON.stringify(prevProps.form?.getState());
const nextValue = JSON.stringify(nextProps.form?.getState());
return prevValue === nextValue;
};
const FormRenderer = ({ handleSubmit }) => {
return (
<form onSubmit={handleSubmit}>
{/* The fields here */}
</form>
);
};
export default React.memo(FormRenderer, propsAreEqual);