Struggling for the performance of truly large React forms

On one of the projects, we came across forms from several dozen blocks that depend on each other. As usual, we cannot talk about the task in detail because of the NDA, but we will try to describe our experience of “taming” the performance of these forms using an abstract (even slightly non-life) example. I'll tell you what conclusions we have drawn from a React project with Final-form.



image


Imagine that the form allows you to obtain a foreign passport of a new sample, while processing the receipt of a Schengen visa through an intermediary - a visa center. This example seems bureaucratic enough to demonstrate our complexities.



So, on our project, we are faced with a form of many blocks with certain properties:



  • Among the fields there are input boxes, multiple selection, autocomplete fields.
  • The blocks are linked together. Suppose, in one block you need to specify the data of the internal passport, and just below there will be a block with the data of the visa applicant. In this case, the agreement with the visa center is also drawn up for an internal passport.

  • – , , ( 10 , ) .
  • , , . , 10- , . : .
  • . . .


The final form occupied about 6 thousand pixels vertically - that's about 3-4 screens, in total, more than 80 different fields. In comparison with this form, applications on the State Services do not seem so great. The closest thing in terms of the abundance of questions is probably a questionnaire of the security service to some large corporation or a boring opinion poll about the preferences of video content.



Large forms are not so common in real problems. If you try to implement such a form “head-on” - by analogy with how we are used to working with small forms - then the result will be impossible to use.



The main problem is that as you enter each letter in the appropriate fields, the entire form will be redrawn, which entails performance problems, especially on mobile devices.



And it is difficult to cope with the form not only for end users, but also for the developers who have to maintain it. If you do not take special steps, the relationship between fields in the code is difficult to track - changes in one place entail consequences that are sometimes difficult to predict.



How we deployed Final-form



The project used React and TypeScript (as we completed our tasks, we completely switched to TypeScript). Therefore, to implement the forms, we took the React Final-form library from the creators of Redux Form.



At the start of the project, we split the form into separate blocks and used the approaches described in the documentation for Final-form. Alas, this led to the fact that the input in one of the fields threw a change in the entire large form. Since the library is relatively recent, the documentation there is still young. It does not describe the best recipes to improve the performance of large molds. As I understand it, very few people are faced with this on projects. And for small forms, a few extra redraws of the component have no effect on performance.



Dependencies



The first obscurity that we had to face was how exactly to implement the dependency between the fields. If you work strictly according to the documentation, the overgrown form starts to slow down due to the large number of interconnected fields. The point is dependencies. The documentation suggests putting a subscription to an external field next to the field. This is how it was on our project - adapted versions of react-final-form-listeners, which were responsible for connecting the fields, lay in the same place as the components, that is, they were lying in every corner. Dependencies were difficult to track down. This bloated the amount of code - the components were gigantic. And everything worked slowly. And in order to change something in the form, you had to spend a lot of time using the search in all project files (there are about 600 files in the project, of which more than 100 are components).



We have made several attempts to improve the situation.



We had to implement our own selector, which selects only the data needed by a particular block.



<Form onSubmit={this.handleSubmit} initialValues={initialValues}>
   {({values, error, ...other}) => (
      <>
      <Block1 data={selectDataForBlock1(values)}/>
      <Block2 data={selectDataForBlock2(values)}/>
      ...
      <BlockN data={selectDataForBlockN(values)}/>
      </>
   )}
</Form>


As you can imagine, I had to come up with my own memoize pick([field1, field2,...fieldn]).



All this in conjunction with PureComponent (React.memo, reselect)led to the fact that the blocks are redrawn only when the data on which they depend changes (yes, we introduced the Reselect library into the project, which was not previously used, with its help we perform almost all data requests).



As a result, we switched to one listener, which describes all the dependencies for the form. We took the very idea of ​​this approach from the final-form-calculate project ( https://github.com/final-form/final-form-calculate ), adding it to our needs.



<Form
   onSubmit={this.handleSubmit}
   initialValues={initialValues}
   decorators={[withContextListenerDecorator]}
>

   export const listenerDecorator = (context: IContext) =>
   createDecorator(
      ...block1FieldListeners(context),
      ...block2FieldListeners(context),
      ...
   );

   export const block1FieldListeners = (context: any): IListener[] => [
      {
      field: 'block1Field',
      updates: (value: string, name: string) => {
         //    block1Field       ...
         return {
            block2Field1: block2Field1NewValue,
            block2Field2: block2Field2NewValue,
         };
      },
   },
];


As a result, we got the required dependence between the fields. Plus, the data is stored in one place and is used more transparently. Moreover, we know in what order the subscriptions are triggered, since this is also important.



Validation



By analogy with dependencies, we have dealt with validation.



In almost every field, we needed to check whether the person entered the correct age (for example, whether the set of documents corresponds to the specified age). From dozens of different validators scattered across all forms, we switched to one global one, breaking it down into separate blocks:



  • validator for passport data,
  • validator for trip data,
  • for data on previous issued visas,
  • etc.


This almost did not affect performance, but it accelerated further development. Now, when making changes, you do not need to go through the entire file to understand what is happening in individual validators.



Code reuse



We started with one large form, on which we ran our ideas, but over time the project grew - another form appeared. Naturally, on the second form, we used all the same ideas, and even reused the code.



Previously, we have already moved all the logic into separate modules, so why not connect them to the new form? This way we have significantly reduced the amount of code and development speed.



Similarly, the new form now has types, constants and components common to the old one - for example, they have general authorization.



Instead of totals



The question is logical: why didn’t we use another library for forms, since this one had difficulties. But big forms will have their own problems anyway. In the past I have worked with Formik myself. Taking into account the fact that we did find solutions for our questions, Final-form turned out to be more convenient.



Overall, this is a great tool for working with forms. And together with some rules for the development of the code base, he helped us significantly optimize development. An added bonus to all this work is the ability to bring new team members up to date faster.



After highlighting the logic, it became much clearer what a particular field depends on - it is not necessary to read three sheets of requirements in analytics for this. Under these conditions, auditing bugs now takes at least two hours, although it could take a couple of days before all these improvements. All this time, the developer was looking for a phantom error, which is not clear from what it manifests itself.



Authors of the article: Oleg Troshagin, Maxilekt.



PS We publish our articles on several sites on the Runet. Subscribe to our pages in VK , FB , Instagram or Telegram channel to learn about all our publications and other news from Maxilect.



All Articles