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:
Term | Definition |
---|---|
Form | The Form component |
Sub-form | Child components nested under Form , such as Input and Select |
First, let’s look at the requirements for our Form
component.
- 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
- Expose a method to provide the entire form’s state
- Provide a method
$Form.data
for the latest state of the entire form
- Provide a method
- 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.