Why the wheel

One obvious pain point of using forms in React is the need to maintain a large number of values and onchanges, such as a simple login box:

class App extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      username: "".password: ""
    };
  }

  onUsernameChange = e= > {
    this.setState({ username: e.target.value });
  };

  onPasswordChange = e= > {
    this.setState({ password: e.target.value });
  };

  onSubmit = (a)= > {
    const data = this.state;
    // ...
  };

  render() {
    const { username, password } = this.state;

    return( <form onSubmit={this.onSubmit}> <input value={username} onChange={this.onUsernameChange} /> <input type="password" value={password} onChange={this.onPasswordChange} /> <button>Submit</button> </form> ); }}Copy the code

This is already a relatively simple login page, and some pages involve detailed editing, with more than ten or twenty components also common. Once there are many components, there are many disadvantages:

  • Not easy to maintain: takes up a lot of space and hinders visibility.
  • Performance may be affected:setStateThe use of can cause re-rendering if sub-components are not optimised, quite impacting performance.
  • Form verification: it is difficult to carry out form verification uniformly.
  • .

To sum up, as a developer, we desperately want to have a form component that has both these features:

  • Simple and easy to use
  • The parent component can manipulate form data through code
  • Avoid unnecessary component redrawing
  • Support for custom components
  • Support form verification

There are already many solutions in the form component community, such as React-final-Form, FormIK, Ant-Plus, noForm, etc., and many component libraries provide support in different ways, such as Ant-Design.

However, these solutions are more or less heavy, or still not easy to use, natural wheel is the best choice to meet the requirements.

How to make a wheel

The form component is implemented in three main parts:

  • Form: Used to pass form context.
  • Field: form field component for automatic incomingvalueandonChangeGo to the form component.
  • FormStore: Stores form data and encapsulates related operations.

To minimize the use of refs while still manipulating Form data (value, change value, manual validation, etc.), I separate the FormStore used to store the data from the Form component, create it with new FormStore(), and pass it in manually.

It might look something like this:

class App extends React.Component {
  constructor(props) {
    super(props);

    this.store = new FormStore();
  }

  onSubmit = (a)= > {
    const data = this.store.get();
    // ...
  };

  render() {
    return (
      <Form store={this.store} onSubmit={this.onSubmit}>
        <Field name="username">
          <input />
        </Field>
        <Field name="password">
          <input type="password" />
        </Field>
        <button>Submit</button>
      </Form>); }}Copy the code

FormStore

Use to store form data, accept form initial values, and encapsulate actions on form data.

class FormStore {
  constructor(defaultValues = {}, rules = {}) {
    / / form values
    this.values = defaultValues;

    // Form initial value, used to reset the form
    this.defaultValues = deepCopy(defaultValues);

    // Form validation rule
    this.rules = rules;

    // Event callback
    this.listeners = []; }}Copy the code

To make form data changes responsive to form domain components, we use a subscription process to maintain a list of event callbacks in the FormStore. Each Field is created by calling formStore.subscribe (listener) to subscribe form data changes.

class FormStore {
  // constructor ...

  subscribe(listener) {
    this.listeners.push(listener);

    // Returns a function to unsubscribe
    return (a)= > {
      const index = this.listeners.indexOf(listener);
      if (index > - 1) this.listeners.splice(index, 1);
    };
  }

  // Call all listeners when the form changes
  notify(name) {
    this.listeners.forEach(listener= >listener(name)); }}Copy the code

Add get and set functions to get and set the form data. Here, notify(name) is called in the set function to ensure that notifications are triggered for all form changes.

class FormStore {
  // constructor ...

  // subscribe ...

  // notify ...

  // Get the form value
  get(name) {
    // If name is passed, the corresponding form value is returned, otherwise the whole form value is returned
    return name === undefined ? this.values : this.values[name];
  }

  // Set the form value
  set(name, value) {
    // If name is specified
    if (typeof name === "string") {
      // Set the value of name
      this.values[name] = value;
      // Perform form validation, as shown below
      this.validate(name);
      // Notification form changes
      this.notify(name);
    }

    // Set the form values in batches
    else if (name) {
      const values = name;
      Object.keys(values).forEach(key= > this.set(key, values[key])); }}// Reset the form value
  reset() {
    // Clear error messages
    this.errors = {};
    // Reset the default value
    this.values = deepCopy(this.defaultValues);
    // Execute notification
    this.notify("*"); }}Copy the code

For the form validation part, I don’t want to be too complicated, just make some rules

  1. FormStoreConstructorrulesIs an object whose key corresponds to that of the form fieldnameAnd the value is oneCheck the function.
  2. Check the functionParameter takes the value of the form field and the entire form value, which is returnedbooleanorstringType result.
  • trueIndicates that the verification succeeds.
  • falseandstringIndicates that the verification fails andstringThe result represents an error message.

Then skillfully by | | symbol to judge whether to check through, such as:

new FormStore({/* Initial value */, {
  username: (val) = >!!!!! val.trim() ||'User name cannot be empty'.password: (val) = >!!!!! (val.length >6 && val.length < 18) | |'Password must be more than 6 characters long and less than 18 characters long'.passwordAgain: (val, vals) = > val === vals.password || 'Inconsistent passwords entered twice'
}})
Copy the code

Implement a validate function in FormStore:

class FormStore {
  // constructor ...

  // subscribe ...

  // notify ...

  // get

  // set

  // reset

  // Used to set and get error messages
  error(name, value) {
    const args = arguments;
    // If no argument is passed, the first error message is returned
    // const errors = store.error()
    if (args.length === 0) return this.errors;

    // If the name passed is of type number, error I is returned
    // const error = store.error(0)
    if (typeof name === "number") {
      name = Object.keys(this.errors)[name];
    }

    // If value is passed, set or delete the error message corresponding to name according to value
    if (args.length === 2) {
      if (value === undefined) {
        delete this.error[name];
      } else {
        this.errors[name] = value; }}// Return an error message
    return this.errors[name];
  }

  // For form validation
  validate(name) {
    if (name === undefined) {
      // Iterate over the entire form
      Object.keys(this.rules).forEach(n= > this.validate(n));
      // Notify the entire form of changes
      this.notify("*");
      // Returns an array containing the first error message and the form value
      return [this.error(0), this.get()];
    }

    // Get the verification function according to name
    const validator = this.rules[name];
    // Get the form value according to name
    const value = this.get(name);
    // Execute the checksum function to get the result
    const result = validator ? validator(name, this.values) : true;
    // Get and set the error message in the result
    const message = this.error(
      name,
      result === true ? undefined : result || ""
    );

    // Return the Error object or undefind, and the form value
    const error = message === undefined ? undefined : new Error(message);
    return[error, value]; }}Copy the code

Now that the Form component’s core FormStore is complete, it’s time to use it in the Form and Field components.

Form

The Form component is fairly simple, and is just there to provide an entry and delivery context.

The props receives an instance of FormStore and passes it through the Context to the child component (Field).

const FormStoreContext = React.createContext();

function Form(props) {
  const { store, children, onSubmit } = props;

  return (
    <FormStoreContext.Provider value={store}>
      <form onSubmit={onSubmit}>{children}</form>
    </FormStoreContext.Provider>
  );
}
Copy the code

Field

The Field component is also not complicated, and the core goal is to automatically pass value and onChange into the form component.

// Get the form value from the onChange event, which deals mainly with the checkbox special case
function getValueFromEvent(e) {
  return e && e.target
    ? e.target.type === "checkbox"
      ? e.target.checked
      : e.target.value
    : e;
}

function Field(props) {
  const { label, name, children } = props;

  // Get the FormStore instance passed from Form
  const store = React.useContext(FormStoreContext);

  // The internal state of the component that triggers the re-rendering of the component
  const [value, setValue] = React.useState(
    name && store ? store.get(name) : undefined
  );
  const [error, setError] = React.useState(undefined);

  // Form component onChange event, used to get the form value from the event
  const onChange = React.useCallback(
    (. args) = >name && store && store.set(name, valueGetter(... args)), [name, store] );// Subscription form data changes
  React.useEffect((a)= > {
    if(! name || ! store)return;

    return store.subscribe(n= > {
      // The current name data is changed, get the data and re-render
      if (n === name || n === "*") { setValue(store.get(name)); setError(store.error(name)); }}); }, [name, store]);let child = children;

  // If children is a valid component, pass in value and onChange
  if (name && store && React.isValidElement(child)) {
    const childProps = { value, onChange };
    child = React.cloneElement(child, childProps);
  }

  // Form structure, the specific style is not posted
  return (
    <div className="form">
      <label className="form__label">{label}</label>
      <div className="form__content">
        <div className="form__control">{child}</div>
        <div className="form__message">{error}</div>
      </div>
    </div>
  );
}
Copy the code

Now that the form component is complete, enjoy using it:

class App extends React.Component {
  constructor(props) {
    super(props);

    this.store = new FormStore();
  }

  onSubmit = (a)= > {
    const data = this.store.get();
    // ...
  };

  render() {
    return (
      <Form store={this.store} onSubmit={this.onSubmit}>
        <Field name="username">
          <input />
        </Field>
        <Field name="password">
          <input type="password" />
        </Field>
        <button>Submit</button>
      </Form>); }}Copy the code

conclusion

This is just the core of the code, not as functional as the hundreds of star components, but simple enough to handle most of the project.

I’ve refined some of the details and released an NPM package, @react-hero/form, which you can install via NPM or find the source on Github. If you have any suggestions or suggestions, feel free to discuss them in the comments or issue.