抹桥的博客
Language
Home
Archive
About
GitHub
Language
主题色
250
1585 words
8 minutes
An Approach to Refactoring a React Form Component

Recently, I refactored the Form component within our team’s internal React component library (ne-rc). This post documents the thought process behind it.

Some preliminary definitions:

TermDefinition
FormThe Form component
Sub-formChild components nested under Form, such as Input and Select

First, let’s look at the requirements for our Form component.

  1. Get the current state of changing form fields
    • Validate if all required fields are completed
    • Expose a method formFieldChange to trigger specific field changes externally
  2. Expose a method to provide the entire form’s state
    • Provide a method $Form.data for the latest state of the entire form
  3. Submission method
    • Validate if the form passes validation
    • Expose a method formSubmit externally

Next, let’s examine how this problem was addressed before and after the refactor.

Before#

Getting the Current State of Changing Form Fields#

How to Get Changing Sub-forms#

React parent-child communication requires passing methods via props. For changes in sub-forms like Input nested under Form to notify the parent, if not using a third-party event passing method, the only way is for the parent to pass a formFieldChange method (let’s assume this name) via props to Input, which then calls formFieldChange when the child component changes.

So, the question is, when do we pass this method?

We can’t register this method for each form field every time it’s used on a specific page. That would mean binding such events to sub-forms every time the form component is used, which is too cumbersome.

So initially, I chose to recursively traverse the children of Form. Whenever a child was identified as a desired form type, I would clone it, inject formFieldChange, and replace the original component.

/**
 * 获取 form 下面每一个表单对象,注入属性,并收集起来
 * @param children
 * @returns {*}
 */
function getForms(children) {
  return React.Children.map(children, (el, i) => {
    if (!el) {
      return null;
    }
    switch (el.type) {
      case Input:
        Forms.push(el);
        return React.cloneElement(el, {
          key: i,
          formFieldChange,
          emptyInput,
        });
      case Select:
        Forms.push(el);
        return React.cloneElement(el, {
          key: i,
          formFieldChange,
        });
      case CheckBox:
        Forms.push(el);
        return React.cloneElement(el, {
          key: i,
          formFieldChange,
        });
      default:
        if (el.props && el.props.children instanceof Array) {
          const children = getForms(el.props.children);
          return React.cloneElement(el, {
            key: i,
            children,
          });
        } else {
          return el;
        }
    }
  });
}

This way, all specific child components could receive the registered method. Taking Input as an example, calling formFieldChange (passed via parent props) within Input’s onChange method would notify the Form component.

Collecting Data from Changing Form Fields#

Once the previous step was complete, this step became simpler. When Input called formFieldChange, it would pass the desired data as an argument. The Form component would then process this argument to get the current state data of the changing field.

Validating if the Form is Completed#

Previously, we collected data for each changing form field. However, to determine if all fields under the current Form were completed, we first needed to know how many fields required input. Then, we could perform the check during formFieldChange. How did we know in advance how many fields needed to be filled? I previously opted to initialize a data structure containing the initial state of all form fields when using the Form.

export default class Form extends React.Component {
  constructor (props) {
    super(props)
    this.Forms = []
    this.formState = Object.assign({}, {
      isComplete: false,
      isValidate: false,
      errorMsg: '',
      data: {}
    }, this.props.formState)
  }

  static propTypes = {
    onChange: PropTypes.func,
    onSubmit: PropTypes.func,
    formState: PropTypes.object
  }
  // 初始化一个类似这样的对象传递给 Form
  formState: {
    data: {
      realName: {},
      cityId: {},
      email: {},
      relativeName: {},
      relativePhone: {},
      companyName: {}
    }
  }
}

This brute-force approach solved the problem, but it introduced many issues.

Limiting to specific component types (Input, Select, CheckBox) hindered extensibility. If we encountered other types, such as custom sub-forms, during development, the Form wouldn’t be able to collect data from them, making it difficult to resolve.

Therefore, I started considering an alternative implementation: Form would only collect components under a specific condition. As long as a component met this condition and implemented the corresponding interface, Form could collect and process it. This significantly improved applicability.

Exposing a Method to Provide the Entire Form’s State#

The entire Form state was obtained by listening for the onChange event triggered by Form externally.

Submission Method#

Validating Form Submission#

With the entire Form data object, validation wasn’t difficult. If validation passed, the formSubmit method was called. If it failed, error messages were added to the Form’s state externally.

Triggering the formSubmit Method Externally#

When the form passed validation, the formSubmit method was triggered externally, passing the data to be submitted as an argument.

After#

The preceding section outlined some of the ideas behind the previously written Form component, which largely met business requirements in practice.

However, the overall extensibility of the Form was poor, making it difficult to integrate other custom components effectively. This led to the idea of a rewrite.

For the rewritten Form, my vision was: first, it must be easy to use, requiring minimal setup; second, it must be highly extensible. Beyond the built-in Input, Select, etc., that can be integrated, any other component needed for special business requirements should be easily integrable with Form simply by implementing a specific interface, without extensive modifications to the component’s internal code.

The refactor primarily focused on the content of requirement 1 above: getting the current state of changing form fields.

Getting the current form state can be broken down into the following points:

  • Get all sub-forms (formFields) that need to be collected
  • Initialize Form state
  • Update the formFields created in point 1 when the number or type of sub-forms under the form changes
  • Notify the parent form when a sub-form’s internal state changes

Getting the Current State of Changing Form Fields#

Getting All Required Sub-forms#

Similarly, we recursively traverse children to get the sub-forms that need to be collected, deciding whether to collect them based on whether the sub-form’s type.name naming convention matches our definition. Let’s look directly at the code:

collectFormField = (children) => {
  const handleFieldChange = this.handleFieldChange;

  // 简单粗暴,在 Form 更新的时候直接清空上一次保存的 formFields,全量更新,
  // 避免 formFields 内容或者数量发生变化时 this.formFields 数据不正确的问题
  const FormFields = (this.formFields = []);

  function getChildList(children) {
    return React.Children.map(children, (el, i) => {
      // 只要 Name 以 _Field 开头,就认为是需要 From 管理的组件
      if (!el || el === null) return null;

      const reg = /^_Field/;
      const childName = el.type && el.type.name;
      if (reg.test(childName)) {
        FormFields.push(el);
        return React.cloneElement(el, {
          key: i,
          handleFieldChange,
        });
      } else {
        if (el.props && el.props.children) {
          const children = getChildList(el.props.children);
          return React.cloneElement(el, {
            key: i,
            children,
          });
        } else {
          return el;
        }
      }
    });
  }
};

As long as a component’s class name starts with _Field, it will be collected and passed the handleFieldChange method. This way, when integrating a custom component, you only need to wrap it and name its class in the _Field prefix format for it to be collected and managed by Form.

What needs to be done inside the integrated component is to call the handleFieldChange method at the appropriate time and pass out the data to be transmitted as an argument.

Why insist on using this inefficient traversal method for collection? It’s actually all for the convenience of using the component. This way, you don’t need to perform any operations on sub-forms every time they are referenced.

Initializing Form state#

In the previous step, all sub-forms were obtained. Then, by calling initialFormDataStructure, the structure of Form’s state.data was initialized, and Form changes were simultaneously notified externally.

When the Number or Type of Sub-forms Changes#

When child components under Form are added or removed, the Form data structure needs to be updated promptly. By calling updateFormDataStructure, newly added or modified sub-forms are updated to the latest state, and Form changes are notified externally.

When a Sub-form’s Internal State Changes#

In the first step of collecting sub-forms, handleFieldChange was already injected into the sub-form components, so the sub-form determines when to call it. When handleFieldChange is called, the Form state is first updated, then external notification is sent that the sub-form has changed, and simultaneously, external notification is sent that the Form has changed.

This seemed to complete the entire process, but in reality, many issues existed.

Firstly, because setState is an asynchronous process, the latest state can only be obtained after render. This means that if setState is called multiple times within a single lifecycle loop, reading state between two calls is likely to be inaccurate. (For detailed information on lifecycles, you can refer to this article: https://www.w3ctech.com/topic/1596)

Therefore, I created a temporary variable currentState to store the latest state in its current condition, updating it every time setState was called.

Another issue was that updateFormDataStructure was called too frequently when the Form changed. In reality, the Form state structure only needs to be updated when the number or type of sub-forms changes. Directly comparing whether the sub-form types have changed is also a very expensive operation, so another compromise was chosen. By marking the Form’s current status, all possible states of the Form were identified:

const STATUS = {
  Init: "Init",
  Normal: "Normal",
  FieldChange: "FieldChange",
  UpdateFormDataStructure: "UpdateFormDataStructure",
  Submit: "Submit",
};

This way, updateFormDataStructure is only performed when the Form’s STATUS is Normal. This saves many renders and invalid FormChange events triggered externally.

The submission and external exposure methods for the Form state remained largely consistent with before. With this, the entire refactor of the Form was complete, and the experience in actual projects has been quite good O(∩_∩)O

Form component address: https://github.com/NE-LOAN-FED/NE-Component/tree/master/src/Form

Finally, if you, the reader, have any better ideas, please let me know 😛.

This article was published on March 18, 2017 and last updated on March 18, 2017, 3124 days ago. The content may be outdated.

An Approach to Refactoring a React Form Component
https://blog.kisnows.com/en-US/2017/03/18/react-form-rework-thinking/
Author
Kisnows
Published at
2017-03-18
License
CC BY-NC-ND 4.0