Skip to content

UIForm V3

Jimmy Somsanith edited this page Nov 26, 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 needs. It allows the backend to control the forms with json documents.

Json is really limited and static, and it introduces limitations in the features. To overcome some of them, and to comply to tcomp/takokit lifecycle, we introduced triggers. It gives 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 code (component and javascript). For the backend developers, the json validation format must be flexible enough too express complex use cases.

b. Implementation proposal

The json schema is too deep in our current implementation, making it very hard to extract. The plan would be to use an existing library to make forms in react, but this time, without json schema in mind.

We would make widgets to fit the form library and to apply the Talend style. Developers could use the library and the common widgets to build frontend only forms.

For backend controlled forms, we would write an extra layer on top of the library, converting the json/ui schema into javascript code to build the form, based on the library and the widgets. This schema layer won't interfere with the forms implementation itself, and we wil be able to modify the json format to fit our needs.

c. 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 form 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

1.0 Github stars Contributors Issues Winner
Formik Jul 2018 19.1k 268 376
React-hook-forms Mar 2019 4.2k 38 1

Maintenance (current time)

Maintained by Issues Response time Well tested Winner
Formik @jaredpalmer 376 From 3 days to no response Unit tests (react-testing-library)
React-hook-forms @bluebill1049 1 Same day Unit tests (@testing-library/react-hooks) + E2E tests (Cypress)

Bundle criteria

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

Performance

Comment Winner
Formik It produces a lot of rerenders. Everytime a field changes, it rerenders
React-hook-forms It optimises the renders

Documentation

Comment Winner
Formik Great api documentation, migration pages, simple guides (arrays, validation, ...)
React-hook-forms Great api documentation, advanced guides (custom widget, accessibility, wizard, ...)

Result

Formik React-hook-forms
Github
Maintenance
Bundle
Performance
Documentation

Formik

Formik was created by @jaredpalmer who is very active in the frontend community. The library is very popular. 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 frontend-only part.

Basis

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

Validation

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

Advanced

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

Wrap up

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": "aze@aze.com"
    }
  }
}
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 initial values

To do a nested structure, we describe it in the json schema. Then, in the ui schema, we refer to nested field via dot notation.

Formik

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

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

  return (
    <Formik
      initialValues={{ user: { email: "aze@aze.com" } }}
      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={handleSubmit(onSubmit)} noValidate>
      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          name="user.email"
          defaultValue="aze@aze.com"
          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>
  );
}

React-hook-form uses native elements (form, input, ...), allowing to write classical html/jsx. To plug the elements to the form system we pass a function via ref. The default values are passed directly to the inputs, instead of a big object on root element like in Formik.

Wrap up

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

⬆️Back to scenarios

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": "aze@aze.com"
    }
  }
}
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 it makes the bundle heavier even if you don't use them.

The widget configuration is still in the json/ui schema. We have to know the widget format, and describe the widget inputs. Sometimes we have a lot of nestings, and multi levels nestings, and it becomes hard to understand and maintain the code.

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: "aze@aze.com" } }}
      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="aze@aze.com"
          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>
  );
}

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

Wrap up

Lib Summary Eligible Complexity
UIForm Pass widgets as form props to register them. UIForm comes with a set of widgets which is good in term of usability, but bad in term of bundle size. 😎(simple structure), 😭 (complex structure)
Formik Import widget and use it as Field. 😎
React-hook-form Import widget and use it as component. Wet need to manage unregister. 😎

⬆️Back to scenarios

c. Scenario V1: simple validation

Simple validation

Let's try to add simple validations. By simple, we mean on single field, 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. user@email.com"
    },
    {
      "key": "password",
      "title": "Password",
      "type": "password"
    }
  ],
  "properties": {}
}

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

  • to set a field as 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

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: "aze@aze.com" } }}
      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']}
+                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']}
+                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 validation 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: "Email is required", 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>
  );
}

React-hook-form waits for validation in the register function. I comes with some default validation like required, and can be composed with custom validations.

Validation is done

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

Wrap up

Lib Summary Eligible Complexity
UIForm Configuration in json with some natively supported cases. If a use case is not covered, we can implement props.customValidation. 😎
Formik We must implement each validation in js, and set the functions on the fields. 😎
React-hook-form We must implement each validation, and pass them to register(), some cases such as required are natively supported. 😎

⬆️Back to scenarios

d. Scenario V2: async validation

UIForm

{
  "jsonSchema": {
    "type": "object",
    "properties": {
      "email": {
        "type": "string"
      }
    }
  },
  "uiSchema": [
    {
      "key": "email",
      "title": "Email",
+      "triggers": [{ "type": "validation" }]
    }
  ],
  "properties": {}
}

We add a trigger definition that will be passed as argument to an onTrigger() function.

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

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

+  const onTrigger = (event, { schema, value, trigger }) => {
+    if(trigger.type === 'validation' && schema.key === 'email') {
+      return asyncValidation(schema, value)
+        .then(() => ({
+          errors(oldErrors) {
+            const newErrors = {...oldErrors};
+            delete newErrors[schema.key];
+            return newErrors;
+          })
+        )
+        .catch(error => ({
+          errors:(oldErrors)=> ({ ...oldErrors, [schema.key]: error }))
+        });
+    }
+  };

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

Triggers allows to do async validation, returning an error modifier as a Promise result. There are some negative points to highlight

  • during the call, the form is not blocked, submission is possible
  • we have to implement the error add and removal from the UIForms errors
  • it makes third entry point for validation (json configuration, customValidation prop, trigger)

The developer experience is quite painful.

Formik

function validateEmail(value) {
  return asyncValidation(value).catch(error => ({ value: error.message }));
}
<Field
  id="email"
  type="email"
  name="email"
  aria-describedby="email-errors"
  aria-invalid={errors["email"]}
  validate={validateEmail}
  required
/>

Formik uses the same validation prop as synchronous validation. But instead of returning the errors, you can return a promise, resolving possible errors.

Submit is suspended until the validation succeeds.

React-hook-form

function validateEmail(value) {
  return asyncValidation(value).catch(error => error.message);
}
<input
  id="email"
  type="email"
  name="email"
  ref={register({ required: true, validate: validateEmail })}
  aria-describedby="email-errors"
  aria-invalid={errors["email"]}
  required
/>

Same comments as Formik, React-hook-form uses the same validation mecanism, but using promises.

Submit is suspended until the validation succeeds.

Wrap up

Lib Summary Eligible Complexity
UIForm Incomplete, workaround via trigger, but it hurts DX. 😭
Formik Same validation mecanism as synchronous validation, via promise. 😎
React-hook-form Same validation mecanism as synchronous validation, via promise. 😎

⬆️Back to scenarios

e. Scenario V3: complex validation

For now, we only did isolated field validation. Let's try to make a non-isolated one. In the example, we have a password field and an extra field that must not be the password value.

Complex validation

What is tricky here is that

  • you change the extra field value, it must check it against the password value
  • you change the password, it must update the extra field error

UIForm

We cannot use

  • json schema: no built-in validation of this type
  • custom validation: it has access to the whole values, but it can only act on an isolated field error. Changing the password value won't update the extra field error.

What is left is the trigger.

{
  "jsonSchema": {
    "type": "object",
    "properties": {
      "email": {
        "type": "string"
      },
      "password": {
        "type": "string"
      },
      "extra": {
        "type": "string"
      }
    }
  },
  "uiSchema": [
    {
      "key": "email",
      "title": "Email",
    },
    {
      "key": "password",
      "title": "Password",
+      "triggers": [{ "type": "checkPwdAndExtraDiff" }]
    },
    {
      "key": "extra",
      "title": "Extra field",
+      "triggers": [{ "type": "checkPwdAndExtraDiff" }]
    },
  ],
  "properties": {}
}
import { UIForm } from '@talend/react-forms/lib/UIForm';
import data from './schema.json';

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

  const onTrigger = (event, { schema, value, trigger, properties }) => {
    if(trigger.type === 'checkPwdAndExtraDiff') {
      const {email, extra} = properties;
      if(email && extra && email === extra) {
        return {
          errors: (oldErrors) => {
            const newErrors = {
              ...oldErrors,
              extra: 'It must not be your password'
            };
            return newErrors;
          }
        }
      } else {
        return {
          errors: (oldErrors) => {
            const newErrors = {...oldErrors};
            delete newErrors.extra;
            return newErrors;
          })
        }
      }
    }
  };

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

But there are some limitations again. If the extra field is required, and has the "required" error displayed, changing the password will remove this "required" error.

We don't have any way to solve that today.

Formik

With formik we have 2 possible validations:

  • single field validation (function you pass on the Field component)
  • a form global validation (function you pass on the Form component)

Both are triggered on submit and on each field validation.

const validate = values => {
  const errors = {};

  const { email, extra } = properties;
  if (email && extra && email === extra) {
    errors.extra = "It must not be your password";
  }

  return errors;
};

This validation function doesn't need to clean the previous error.

function ExampleForm() {
  return (
    <Formik
      initialValues={{ user: { email: "aze@aze.com" } }}
      onSubmit={onSubmit}
      validate={validate}
    >
      {args => {
        /* ... */
      }}
    </Formik>
  );
}

The validations (global and from fields) are merged. So no conflict, when there is a global error, it takes priority, when fixed, we still have the field validation that will display.

React-hook-form

const { setError, getValues } = useForm();

function isPwdValueEquals() {
  const { password, extra } = getValues();
  return mail && extra && email === extra;
}

function validatePassword(pwd) {
  if (isPwdValueEquals()) {
    // set the error
    setError(
      "extra", // field
      "checkPwdValueDiff", // error id
      "Must not be the same as password" // error message
    );
  } else {
    // clean the errors
    setError("extra", "checkPwdValueDiff", true);
  }
}

function validateExtra(value) {
  if (isPwdValueEquals()) {
    return "Must not be the same as password";
  }
}
import React from "react";
import useForm from "react-hook-form";

function App() {
  // ...

  return (
    <form onSubmit={onSubmit} noValidate>
      {/* ... */}

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

      <div className="form-group">
        <label htmlFor="extra">Extra field</label>
        <input
          id="extra"
          type="text"
          name="extra"
          ref={register({
            required: true,
            validate: { checkPwdValueDiff: validateExtra } // attach the validation message to "checkPwdValueDiff" error id
          })}
          aria-describedby="extra-errors"
          aria-invalid={errors["extra"]}
          required
        />
        <div id="extra-errors">{errors["password"]}</div>
      </div>

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

React-hook-form allows to manage the case. But if we want to interact with a field error, from another field, we need to

  • double the validation (here from password and from extra)
  • clean the error (here clean extra error from password)

There is no constrains in what validation can be done, but we need to synchronize multiple validations.

Wrap up

Lib Summary Eligible Complexity
UIForm Possible with the triggers, but some big limitations for not so complicated use cases. 😭
Formik Single and global validation points, well managed/merged 😎
React-hook-form The hook provides the functions to get values and set errors, we are free to do complex validations 😐

⬆️Back to scenarios

f. Scenario A1: conditional rendering

Conditional rendering

UIForm

{
  "jsonSchema": {
    "type": "object",
    "title": "Comment",
    "properties": {
      "email": {
        "type": "string"
      },
      "private": {
        "type": "boolean"
      },
      "password": {
        "type": "string"
      }
    }
  },
  "uiSchema": [
    {
      "key": "email",
      "title": "Email"
    },
    {
      "key": "private",
      "title": "Private",
      "widget": "toggle"
    },
    {
      "key": "password",
      "title": "Password",
      "type": "password",
      "condition": {
        "===": [{ "var": "private" }, true]
      }
    }
  ],
  "properties": {}
}

Conditional rendering is a built-in feature in UIForm.

The password ui schema has condition property. It uses jsonLogic to express conditions based on other values.

Formik

function ExampleForm() {
  return (
    <Formik>
      {({ values }) => (
        <Form>
          <div className="form-group">
            <label htmlFor="email">Email</label>
            <Field id="email" type="email" name="email" />
          </div>

          <div className="form-group">
            <Field id="private" type="checkbox" name="private" />
            <label htmlFor="private">Private</label>
          </div>

          {values.private && (
            <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>
  );
}

The forms values are available as parameters of the child function, and are updated everytime there is a change. We just have to add a condition on the component rendering in jsx.

React-hook-form

function App() {
  const { register, watch } = useForm();
  const privateValue = watch("private");

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

      <div className="form-group">
        <input id="private" type="checkbox" name="private" ref={register} />
        <label htmlFor="private">Private</label>
      </div>

      {privateValue && (
        <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>
  );
}

React-hook-form optimises the renders. So on input value change, the form is not necessarily re-rendered. To make sure the checkbox value triggers a re-render, we have to watch it. And we display the password depending on its value, in jsx.

Wrap up

Lib Summary Eligible Complexity
UIForm Easy with jsonLogic syntax 😎
Formik Easy, just an if 😎
React-hook-form Easy, an if 😎

⬆️Back to scenarios

g. Scenario A2: conditional require

Conditional require

Let's try a case that requires to change the form structurally. We want to have a non-required input, that turns required when another field is true.

UIForm

There is no built-in way to change a field configuration. We need to change its schema. This complicates a lot the code

  • save the json/ui schema in a state management
  • get the json/ui schema from the state management to pass it to the UIForm component
  • on value change, if it's a condition to change a field, update the json/ui schema, and replace it in state manager

It involves to have a lot heavier infrastructure.

There is a workaround that is more used: conditional rendering. The idea is to condition parts of the form, and render them depending on values. In the not so complicated example, we would have 2 passwords ui configuration (one required, the other not), that are conditioned depending on the toggle value to display one or the other. But this results in a complicated json, with sometimes a lot of duplications.

Formik

<Formik onSubmit={onSubmit}>
  {args => {
    const { values } = args;

    function validate(values) {
      if (values.private && !values.password) {
        return { password: "Password is required" };
      }
    }

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

        <div className="form-group">
          <label htmlFor="private">Password</label>
          <Field id="private" name="private" component={Toggle} />
        </div>

        <div className="form-group">
          <label htmlFor="email">Password</label>
          <Field
            id="password"
            type="password"
            name="password"
            required={values.private}
          />
        </div>

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

Formik gives all the values, you can add a condition to the required props to pass to the input field.

React-hook-form

function App() {
  const { register, handleSubmit, watch } = useForm();
  const privateValue = watch("private");

  function validatePassword(password) {
    if (privateValue && !password) {
      return "Password is required";
    }
  }

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

      <Toggle id="private" name="private" />

      <div className="form-group">
        <label htmlFor="email">Password</label>
        <input
          id="password"
          type="password"
          name="password"
          ref={register({ validate: validatePassword })}
          required={privateValue}
        />
      </div>

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

As Formik, we can simply add a condition on the required props. To get the value, we have to watch it.

Wrap up

Lib Summary Eligible Complexity
UIForm Not possible, workaround via conditional rendering, but it becomes messy 😭
Formik Simply add a condition on required props and implement validation 😎
React-hook-form Simply add a condition on required props and implement validation 😎

⬆️Back to scenarios

h. Result

Possibility

UIForms Formik React-hook-form
Simple forms
Custom widget ❌ for complex structure, ✅ for simple structure
Simple validation ✅ (with the unique error message limitation)
Async validation
Complex validation
Conditional rendering
Conditional require

Complexity

UIForms Formik React-hook-form
Simple forms 😎 😎 😎
Custom widget 😎 😎 😎
Simple validation 😎 😎 😎
Async validation 😭 😎 😎
Complex validation 😭 😎 😐
Conditional rendering 😎 😎 😎
Conditional require 😭 😎 😎

4. Json Schema to lib


Clone this wiki locally