Skip to content

UIForm V3

Jimmy Somsanith edited this page Nov 18, 2019 · 15 revisions

1. The need

a. Schema vs components

Today, @talend/ui repository has a form package. It proposes 1 api, based on json schema format. This has been done to fill tcomp/tacokit, to let the backend services control the forms with json format based document.

Json is really limited and static, and this introduces limitations in the features. To overcome some of them, and to comply in tcomp/takokit lifecycle, we introduced triggers, to have an external entry to do custom actions (change of schema, dynamic/async validation, change of suggestions, …). But the implementation is really hard to maintain and synchronize. For example validation has 3 entry points:

  • (backend) Json Schema: static validation on single elements
  • (frontend) Component custom validation props: complex single element validation
  • (frontend/backend) Trigger: static/async validation on global/single element(s)

The result is that

  • Frontend developers struggle to create frontend-only forms, writing tons of json instead of components, without being able to fulfill complex cases
  • Backend developers struggle too, trying to implement complex validations with json and UIForm limited features.

We need to open this implementation, to let frontend developers write their custom/complex use cases, and have a validation format flexible enough, that we can convert to the open implementation.

b. Complex use cases examples

(TODO)

2. External libs

@talend/react-forms has a custom implementation. The json schema is present at every level of the implementation, from top level form to widget internal code. It’s very hard to extract the schema part to a top level to allow developers to use the widgets as components. A better and less costly solution would be to base the implementation on an existing library. The developers would be able to use the library (or the wrapper on it), and backend users would still be able to use our custom schema layer on top of it.

a. Candidates

Github criteria

Github stars Maintained Contributors Issues
Formik 19.1k By @jaredpalmer 268 376
React-hook-forms 4.2k By @bluebill1049 38 1

Bundle criteria

Weekly dl Size Dependencies
Formik 307k 12.6kB 9
React-hook-forms 30k 5.2kB 0

Formik

Formik was created by @jaredpalmer who is very active in the frontend community. The library is very popular and well maintained. It has tons of contributors and more than 19k stars.

React-hook-form

React-hook-form is quite young, it was created in march 2019 by @bluebill1049, but has risen quite fast, having now more than 4k stars. It is a very light library, with no dependencies. It has a documentation for advanced cases such as custom widget, accessibility, or wizard.

b. Scenarios

The goal is to compare the developer experience and the possibilities between the 2 libraries and our current implementation. We will focus on the component part, as we don’t want to break the api for schema part.

Basis

Scenario Story
B1 as a developer, I want to create a simple form with email and password, with submit function
B2 as a developer, I want to add a custom widget

Validation

Scenario Story
V1 as a developer, I want to validate the email pattern and password requirement
V2 as a developer, I want to async validate the email, checking it’s availability
V3 as a developer, I want to do a complex validation, with values dependencies

Advanced

Scenario Story
A1 as a developer, I want to set a field required depending on another value
A2 as a developer, I want to show/hide a new field depending on another value

3. Tests

a. Scenario B1: simple form

Simple form

Schema

{
  "jsonSchema": {
    "type": "object",
    "properties": {
      "user": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string"
          }
        }
      },
      "password": { "type": "string" }
    }
  },
  "uiSchema": [
    {
      "key": "user.email",
      "title": "Email"
    },
    {
      "key": "password",
      "title": "Password"
    }
  ],
  "properties": {
    "user": {
      "email": "[email protected]"
    }
  }
}
import { UIForm } from "@talend/react-forms/lib/UIForm";
import data from "./schema.json";

function ExampleForm() {
  const onSubmit = (event, data) => {
    console.log(data);
  };

  return <UIForm data={data} onSubmit={onSubmit} />;
}

On a simple form, it stays simple. You have 3 parts:

  • json schema: defines the model
  • ui schema: defines the widgets
  • properties: defines the default values

Formik

import React from "react";
import { Formik, Form, Field } from "formik";

function ExampleForm() {
  const onSubmit = data => {
    console.log(data);
  };

  return (
    <Formik
      initialValues={{ user: { email: "[email protected]" } }}
      onSubmit={onSubmit}
    >
      {args => {
        const { isSubmitting } = args;

        return (
          <Form>
            <div className="form-group">
              <label htmlFor="email">Email</label>
              <Field id="email" type="email" name="user.email" />
            </div>

            <div className="form-group">
              <label htmlFor="email">Password</label>
              <Field id="password" type="password" name="password" />
            </div>

            <button type="submit" disabled={isSubmitting}>
              Submit
            </button>
          </Form>
        );
      }}
    </Formik>
  );
}

Formik offers to write javascript instead of json. Each form and field components come from the library.

Compared to the schema

  • the structure is tied to the field names
  • the default values are passed at Formik component level

There are some extra features to manage some status. In the example above, the isSubmitting flag allows to avoid submitting twice the form.

React-hook-form

import React from "react";
import useForm from "react-hook-form";

function App() {
  const { register, handleSubmit } = useForm();

  const onSubmit = data => {
    console.log(data);
  };

  return (
    <form onSubmit={onSubmit} noValidate>
      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          name="user.email"
          defaultValue="[email protected]"
          ref={register}
        />
      </div>

      <div className="form-group">
        <label htmlFor="email">Password</label>
        <input id="password" type="password" name="password" ref={register} />
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

export default App;

React-hook-form uses native elements (form, input, ...), allowing to write classical html/jsx, but wiring elements to the form system via ref and functions from the hook.

Wrap up

Lib Summary Eligible
UIForm Almost no js code, simple json description.
Formik Components from the lib.
React-hook-form Native elements to wire with hook.

My opinion:

  • React-hook-form: ✅This is clearly an advantage to stay with native elements

b. Scenario B2: Custom widget

KeyValue widget within form

Schema

{
  "jsonSchema": {
    "type": "object",
    "properties": {
      "user": {
        "type": "object",
        "properties": {
          "email": {
            "type": "string"
          }
        }
      },
      "password": { "type": "string" },
+      "extraField": {
+        "type": "object",
+        "properties": {
+          "key": {
+            "type": "string"
+          },
+          "value": {
+            "type": "string"
+          }
+        }
+      }
+    }
+  },
  "uiSchema": [
    {
      "key": "user.email",
      "title": "Email"
    },
    {
      "key": "password",
      "title": "Password"
    },
+    {
+      "key": "extraField",
+      "title": "Extra field",
+      "items": [
+        {
+          "key": "extraField.key",
+          "title": "Key"
+        },
+        {
+          "key": "extraField.value",
+          "title": "Private value",
+          "type": "password"
+        }
+      ],
+      "widget": "keyValue"
+    }
  ],
  "properties": {
    "user": {
      "email": "[email protected]"
    }
  }
}
import { UIForm } from '@talend/react-forms/lib/UIForm';
import data from './schema.json';
+import KeyValue from './KeyValue.component';

function ExampleForm() {
  const onSubmit = (event, data) => {
    console.log(data);
  };

+   const widgets = {
+    keyValue: KeyValue,
+   };

  return (
    <UIForm
      data={data}
      onSubmit={onSubmit}
+      widgets={widgets}
    />
  );
}

UIForm comes with a set of widgets, loaded by default. This allows to not pass the widgets everytime, but makes the bundle heavier even if you don't use them.

The widget configuration is still in the json schema. We have to know describe the widget value format, and describe the widget inputs, sometimes having heavy nestings in term of level and quantity.

Formik

import { Field } from "formik";

function KeyValue({ field }, id, label, keyProps, valueProps) {
  const { name } = field;
  const { label: keyLabel, ...restKeyProps } = keyProps;
  const { label: valueLabel, ...restValueProps } = valueProps;
  const keyName = `${name}.key`;
  const valueName = `${name}.value`;
  const keyId = `${id}.key`;
  const valueId = `${id}.value`;
  return (
    <fieldset>
      <legend>{label}</legend>
      <div className="form-group">
        <label htmlFor={keyId}>{keyLabel}</label>
        <Field id={keyId} type="text" name={keyName} {...restKeyProps} />
      </div>
      <div className="form-group">
        <label htmlFor="email">{valueLabel}</label>
        <Field
          id={valueId}
          type="text"
          name={`${name}.key`}
          {...restValueProps}
        />
      </div>
    </fieldset>
  );
}

The widget use the same Field component from Formik for its inputs.

import React from 'react';
import { Formik, Form, Field } from 'formik';
import KeyValue from './KeyValue.component';

function ExampleForm() {
  const onSubmit = data => {
    console.log(data);
  };

  return (
    <Formik
      initialValues={{ user: { email: "[email protected]" } }}
      onSubmit={onSubmit}
    >
      {args => {
        const { isSubmitting } = args;

        return (
          <Form>
            <div className="form-group">
              <label htmlFor="email">Email</label>
              <Field id="email" type="email" name="user.email" />
            </div>

            <div className="form-group">
              <label htmlFor="email">Password</label>
              <Field id="password" type="password" name="password" />
            </div>

            <div className="form-group">
              <label htmlFor="email">Extra field</label>
              <Field id="password" type="password" name="password" />
            </div>

+            <Field
+              id="extra-field"
+              name="keyValue"
+              component={KeyValue}
+              label="Extra field"
+              keyProps={{ label: 'Key' }}
+              valueProps={{ label: 'Private value', type: 'password' }}
+            />

            <button type="submit" disabled={isSubmitting}>
              Submit
            </button>
          </Form>
        );
      }}
    </Formik>
  );
}

We use the Field component, passing the component to render as component props and name that the widget will manage. The rest of props are just passed to the widget.

React-hook-form

import { useEffect } from "react";

function KeyValue({
  id,
  label,
  name,
  keyProps,
  valueProps,
  register,
  unregister
}) {
  const { label: keyLabel, ...restKeyProps } = keyProps;
  const { label: valueLabel, ...restValueProps } = valueProps;
  const keyId = `${id}.key`;
  const valueId = `${id}.value`;
  const keyName = `${name}.key`;
  const valueName = `${name}.value`;

  useEffect(() => {
    return () => {
      unregister(keyName);
      unregister(valueName);
  }, []);

  return (
    <fieldset>
      <legend>{label}</legend>
      <div className="form-group">
        <label htmlFor={keyId}>{keyLabel}</label>
        <input id={keyId} type="text" name={keyName} ref={register} {...restKeyProps} />
      </div>
      <div className="form-group">
        <label htmlFor="email">{valueLabel}</label>
        <input
          id={valueId}
          type="text"
          name={valueName}
          ref={register}
          {...restValueProps}
        />
      </div>
    </fieldset>
  );
}

In the widget, we use native elements again, passing react-hook-form register() function. But we have to unregister them at widget unmount.

import React from "react";
import useForm from "react-hook-form";
+import KeyValue from "./KeyValue.component";

function App() {
-  const { register, handleSubmit } = useForm();
+  const { register, unregister, handleSubmit } = useForm();

  const onSubmit = data => {
    console.log(data);
  };

  return (
    <form onSubmit={onSubmit} noValidate>
      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          name="user.email"
          defaultValue="[email protected]"
          ref={register}
        />
      </div>

      <div className="form-group">
        <label htmlFor="email">Password</label>
        <input id="password" type="password" name="password" ref={register} />
      </div>

+      <KeyValue
+        id="extra-field"
+        name="keyValue"
+        label="Extra field"
+        keyProps={{ label: 'Key' }}
+        valueProps={{ label: 'Private value', type: 'password' }}
+        register={register}
+        unregister={unregister}
+      />

      <button type="submit">Submit</button>
    </form>
  );
}

export default App;

In the form, we use the component directly in jsx, passing the register/unregister functions.

Wrap up

Lib Summary Eligible
UIForm Pass widgets to the form to registry, it comes with a set of widgets which is good in term of usability, but bad in term of bundle size.
Formik Import widget and use it as Field.
React-hook-form Import widget and use it as jsx component. But need to manage unregister.

My opinion

  • UIForm: This solution is still needed for backend control via json
  • other solutions: ✅ More control to the dev and able to tree shake

c. Scenario V1: simple validation

Simple validation

Let's try to add simple validations. By simple, we mean on single fields, with common basic use cases.

  • email is required and must have the right pattern
  • password is required

UIForm

{
  "jsonSchema": {
    "type": "object",
    "title": "Comment",
    "properties": {
      "email": {
        "type": "string",
+        "pattern": "^\\S+@\\S+$"
      },
      "password": {
+        "type": "string"
      }
    },
    "required": ["email", "password"]
  },
  "uiSchema": [
    {
      "key": "email",
      "title": "Email",
+      "validationMessage": "Please enter a valid email address, e.g. [email protected]"
    },
    {
      "key": "password",
      "title": "Password",
      "type": "password"
    }
  ],
  "properties": {}
}

Once again, UIForm comes with a bunch of default features.

  • to set a field a required, you just have to gather them in a required array.
  • to validate a pattern, you just have to pass the pattern in the json schema.

There is still a limitation, as you can't pass a custom message for a specific validation. For the email, we customize the validation message, but it will display for the required AND the pattern errors.

If you need to do simple custom validations (not covered by the json schema), you can pass a customValidation prop to UIForm

```diff
import { UIForm } from '@talend/react-forms/lib/UIForm';
import data from './schema.json';

function ExampleForm() {
  const onSubmit = (event, data) => {
    console.log(data);
  };

+  function validate(schema, value, allValues) {
+    if (schema.key === "password" && value === "lol") {
+      return "Password must not be lol";
+    }
+  }

  return (
    <UIForm
      data={data}
      onSubmit={onSubmit}
+      customValidation={validate}
    />
  );
}

Validation is done

  • for the field when user finishes to edit (blur)
  • for the entire form (each individual check) on submit

Formik

import React from "react";
import { Formik, Form, Field } from "formik";

+function validateEmail(value) {
+  if (!value) {
+    return "The email is required";
+  } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
+    return "Invalid email address";
+  }
+}

+function validatePassword(value) {
+  if (!value) {
+    return "The password is required";
+  }
+}

function ExampleForm() {
  const onSubmit = data => {
    console.log(data);
  };

  return (
    <Formik
      initialValues={{ user: { email: "[email protected]" } }}
      onSubmit={onSubmit}
+      validateOnChange={false} // this will trigger validation on blur only
    >
      {args => {
-        const { isSubmitting } = args;
+        const { errors, isSubmitting } = args;

        return (
          <Form>
            <div className="form-group">
              <label htmlFor="email">Email</label>
              <Field
                id="email"
                type="email"
                name="email"
+                aria-describedby="email-errors"
+                aria-invalid={errors['email']}
              />
+              <Field id="email" type="email" name="email" validate={validateEmail} required />
+              <ErrorMessage name="email" component="div" id="email-errors" />
            </div>

            <div className="form-group">
              <label htmlFor="email">Password</label>
              <Field
                id="password"
                type="password"
                name="password"
+                aria-describedby="password-errors"
+                aria-invalid={errors['password']}
              />
+              <Field id="password" type="password" name="password" validate={validatePassword} required />
+              <ErrorMessage name="password" component="div" id="password-errors" />
            </div>

            <button type="submit" disabled={isSubmitting}>
              Submit
            </button>
          </Form>
        );
      }}
    </Formik>
  );
}

In Formik, you have to implement the validations functions, returning the error message. It has the advantage to give full control to the dev and we're going to see that it's a game changer in more complex validations.

But we can imagine exposing a set of validation functions for common simple checks.

Validation is done

  • for the field when user on blur
  • for the entire form (each individual check) on submit

React-hook-form

import React from "react";
import useForm from "react-hook-form";

+function validateEmailPattern(value) {
+  return (
+    value && !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)
+  ) || "Invalid email address";
+}

function App() {
-  const { register, handleSubmit } = useForm();
+  const { errors, register, handleSubmit } = useForm({ mode: "onBlur" }); // validation on blur

  const onSubmit = data => {
    console.log(data);
  };

  return (
    <form onSubmit={onSubmit} noValidate>
      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          name="email"
-          ref={register}
+          ref={register({ required: true, validate: validateEmailPattern })}
+          aria-describedby="email-errors"
+          aria-invalid={errors['email']}
+          required
        />
+        <div id="email-errors">{errors['email']}</div>
      </div>

      <div className="form-group">
        <label htmlFor="email">Password</label>
        <input
          id="password"
          type="password"
          name="password"
-          ref={register}
+          ref={register({ required: true, validate: validateEmailPattern })}
+          aria-describedby="password-errors"
+          aria-invalid={errors['password']}
+          required
        />
+        <div id="password-errors">{errors['password']}</div>
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

export default App;

React-hook-form waits for validation

Validation is done

  • for the field when user on blur
  • for the entire form (each individual check) on submit

Wrap up

Lib Summary Eligible
UIForm Configuration in json. If a use case is not covered, we can implement props.customValidation.
Formik We lust implement each validation in js, and set the functions on the fields.
React-hook-form Native elements to wire with hook.

d. Scenario V2: async validation

UIForm

Formik

React-hook-form

Wrap up

e. Scenario V3: complex validation

UIForm

Formik

React-hook-form

Wrap up

f. Scenario A1: conditional require

UIForm

Formik

React-hook-form

Wrap up

g. Scenario A2: conditional rendering

UIForm

Formik

React-hook-form

Wrap up

h. Result

UIForms Formik React-hook-form
Simple forms
Custom widget ❌ for FE only, ✅ for backend control
Simple validation ✅ (with the unique error message limitation)

4. Json Schema to lib