Reasonable form validation tool for reason-react
Clone or download
Latest commit 834339e Oct 20, 2018
Permalink
Failed to load latest commit information.
examples Un-expose React Oct 13, 2018
src Un-expose React Oct 13, 2018
.gitignore Switch to parcel Jun 25, 2018
.travis.yml Add CI Feb 24, 2018
HISTORY.md Update HISTORY Oct 13, 2018
README.md Update README Oct 20, 2018
bsconfig.json Use re-debouncer Oct 8, 2018
package.json v1.0.0-beta.3 Oct 13, 2018
webpack.config.js Initial implementation Dec 3, 2017
yarn.lock Use re-debouncer Oct 8, 2018

README.md

Formality

npm version build status license

Reasonable form validation tool for reason-react.

Features

  • Validation strategies
  • Async validations (debounced on change / on blur)
  • I18n compatible

TOC

Installation

You're looking at 1.0.0-beta README.

# yarn
yarn add re-formality@next
# or npm
npm install --save re-formality@next

Then add it to bsconfig.json:

"bs-dependencies": [
  "re-formality"
]

Examples

Concepts

The main purpose of this library is to provide great form validation UX. To achieve this, Formality follows the following principle:

Validation feedback should be provided as soon as possible but not too soon.

The hardest part is to figure out the right moment when first validation results should be emitted in UI.

Let's break down a case with credit card field. A user opens a form and focuses on a field. When the first character is typed, the field is in an invalid state but it's not really polite to show an error at this point: we should let user a chance to finish what he's doing. While the user is typing, we wait. If after some character, validator reported valid result, it's a proper moment to indicate success in UI (e.g. show credit card type). But if the user left the field in an invalid state (e.g. moved to another field) we have all rights to emit an error. After the first result is emitted, we update validation state in UI on every change.

Sadly, form fields are different and credit card scenario is not universal. This is where strategies kick in.

Strategies

We can't have a single scenario for all the cases but we can spot the most common ones, describe a logic of each and apply proper scenarios to specific form fields. To understand a behavior of each strategy, add the following prefix to its name: "Start providing feedback in UI on..."

module Strategy = {
  type t =
    | OnFirstBlur
    | OnFirstChange
    | OnFirstSuccess
    | OnFirstSuccessOrFirstBlur
    | OnSubmit;
};

OnFirstBlur

Results are emitted on the first blur. After first results are emitted, a user receives feedback on every change in this field.

OnFirstChange

Results are emitted on the first change in a field (basically, as a user types).

OnFirstSuccess

Results are emitted on first successful validation. After first results are emitted, a user receives feedback on every change in this field.

OnFirstSuccessOrFirstBlur

Results are emitted on first successful validation or on the first blur. After first results are emitted, a user receives feedback on every change in this field.

OnSubmit

Results are emitted only after the first submission attempt. After this, results for each field are emitted on every change until the form is reset.

Usage

It takes 3 steps to implement a form:

  1. Define form config.
  2. Create form container.
  3. Render form container and form UI.

Code > 1000 words. Quick example for you:

Spoiler
module LoginForm = {
  open Formality;

  type field =
    | Email
    | Password;

  type state = {
    email: string,
    password: string,
  };

  type message = string;

  module EmailField = {
    let update = (state, value) => {...state, email: value};

    let validator = {
      field: Email,
      strategy: Strategy.OnFirstSuccessOrFirstBlur,
      dependents: None,
      validate: state =>
        switch (state.email) {
        | "" => Error("Uh oh error")
        | _ => Ok(Valid)
        },
    };
  };

  module PasswordField = {
    let update = (state, value) => {...state, password: value};

    let validator = {
      field: Password,
      strategy: Strategy.OnFirstBlur,
      dependents: None,
      validate: state =>
        switch (state.password) {
        | "" => Error("Uh oh error")
        | _ => Ok(Valid)
        },
    };
  };

  let validators = [
    EmailField.validator,
    PasswordField.validator,
  ];
};

module LoginFormContainer = Formality.Make(LoginForm);

let component = React.statelessComponent("LoginForm");

let make = _ => {
  ...component,
  render: _ =>
    <LoginFormContainer
      initialState={email: "", password: ""}
      onSubmit={
        (state, {notifyOnSuccess, notifyOnFailure, reset}) =>
          /* Submit form and either notifyOnSuccess / notifyOnFailure / reset */
      }>
      ...{
           form =>
             <form
               className="form"
               onSubmit={form.submit->Formality.Dom.preventDefault}>
               <input
                 value={form.state.email}
                 disabled={form.submitting}
                 onBlur={_ => form.blur(Email)}
                 onChange={
                   event =>
                     form.change(
                       Email,
                       LoginForm.EmailField.update(
                         form.state,
                         event->ReactEvent.Form.target##value,
                       ),
                     )
                 }
               />
               {
                 switch (Email->form.result) {
                 | Some(Error(message)) =>
                   <div className={Cn.make(["form-message", "failure"])}>
                     message->React.string
                   </div>
                 | Some(Ok(Valid | NoValue))
                 | None => React.null
                 }
               }
               <input
                 value={form.state.password}
                 disabled={form.submitting}
                 onBlur={_ => form.blur(Password)}
                 onChange={
                   event =>
                     form.change(
                       Password,
                       LoginForm.PasswordField.update(
                         form.state,
                         event->ReactEvent.Form.target##value,
                       ),
                     )
                 }
               />
               {
                 switch (Password->form.result) {
                 | Some(Error(message)) =>
                   <div className={Cn.make(["form-message", "failure"])}>
                     message->React.string
                   </div>
                 | Some(Ok(Valid | NoValue))
                 | None => React.null
                 }
               }
               <button disabled={form.submitting}>
                 (form.submitting ? "Submitting..." : "Submit")->React.string
               </button>
             </form>
         }
    </LoginFormContainer>,
};

Form config

Form config is a module:

module MyForm = {
  type field;
  type state;
  type message;
  let validators: list(validator);
};

To make things happen, you must provide few types and list of validators. Depending on whether you need async validations or not, your config will require (or not) additional data. But most of the things are common for all types of forms. Let's start with the simplest case without async validations—this is what's required for all types of forms—and then async differences will be outlined in Async validations section.

type field

A variant where tags are form fields.

type field =
  | Email
  | Password;

type state

state is a record that defines a shape of a form state.

type state = {
  email: string,
  password: string,
};

type message

The type of error messages that will be rendered in UI. Feel free to set it to whatever you need.

The most common scenario is:

type message = string;

If you build i18n'ized app then it's going to be something like this:

type message = I18n.t;

let validators: list(validator)

Field validators.

module EmailField = {
  let validator = {
    field: Email,
    strategy: Strategy.OnFirstSuccessOrFirstBlur,
    dependents: None,
    validate: state =>
      switch (state.email) {
      | "" => Error("Uh oh error")
      | _ => Ok(Valid)
    },
  };
};

let validators = [
  EmailField.validator,
  ...
];
validator

It's a record of 4 items:

type validator('field, 'state, 'message) = {
  field: 'field,
  strategy: Formality.Strategy.t,
  dependents: option(list('field)),
  validate: 'state => Result.t(ok, 'message),
};
strategy

See Strategies.

dependents

Optional list of fields that must be revalidated on a change in the current field. E.g. PasswordConfirmation must be revalidated on a change in Password field:

field: Password,
dependents: [PasswordConfirmation]->Some
validate

A function that takes state and returns Belt's Result.t:

type ok =
  | Valid
  | NoValue;

type validate('state, 'message) = 'state => Result.t(ok, 'message);

Most of the time you need Ok(Valid) or Error('message). You want to return Ok(NoValue) when optional field receives no value (e.g. value == ""). Valid and NoValue are explicitly differentiated since there's no reason to show success message/icon in UI when no value is provided.

Form container

To create form container simply do the following:

module MyFormContainer = Formality.Make(MyForm);

It creates renderable React component for general form.

If you render forms with async validations, use:

/* Async validations on change (debounced) */
module MyAsyncFormContainer = Formality.Async.Make(MyForm);

/* Async validations on blur */
module MyAsyncFormContainer = Formality.Async.MakeOnBlur(MyForm);

Rendering

Form container accepts 3 props:

render: (_) =>
  <MyFormContainer
    initialState={email: "", password: ""}
    onSubmit=((state, {notifyOnSuccess, notifyOnFailure, reset}) => {
      /* Submit form and either notifyOnSuccess / notifyOnFailure / reset */
    })
  >
  ...{form => /* UI */}
  </MyFormContainer>

initialState

It's state record with initial values for each form field.

onSubmit

This handler will be triggered on form submission (only when all validators returned Ok(_)).

It accepts two arguments:

  1. state: current state of a form
  2. submissionCallbacks: record with 3 callbacks
type submissionCallbacks('field, 'state, 'message) = {
  notifyOnSuccess: option('state) => unit,
  notifyOnFailure: (list(('field, 'message)), option('message)) => unit,
  reset: unit => unit,
};
notifyOnSuccess

Trigger this callback when server responded with success. It accepts optional state argument: if it's provided, this state will be set as a next form state.

notifyOnFailure

Trigger this callback when server responded with an error. It accepts 2 arguments:

  1. list of field-level errors
  2. optional message: some information not directly related to any particular field

You can access this data in render via form.status (see below).

reset

Simply reset a form container state.

form => UI

Form container accepts children as a function.

form argument is a record that contains everything you need to render UI:

type form = {
  state: Form.state,
  status: FormStatus.t,
  result: Form.field => option(result),
  submitting: bool,
  change: (Form.field, Form.state) => unit,
  blur: Form.field => unit,
  submit: unit => unit,
  dismissSubmissionResult: unit => unit,
};
form.state

Form state, obviously. Use it to set values of the form fields.

form.status

Form status is a variant:

module FormStatus = {
  type t('field, 'message) =
    | Editing
    | Submitting
    | Submitted
    | SubmissionFailed(list(('field, 'message)), option('message));
};

You can use it to show a spinner while a form is Submitting, or success message on Submitted, or display server errors using data that you passed to notifyOnFailure callback (it's available in SubmissionFailed tag payload).

form.submitting

This prop is passed for convenience (as you will need it to disable form inputs and button while a form is Submitting). Basically, this is true when form.status is Submitting, false otherwise.

form.result

Use this function to get validation result for a field.

switch (Email->form.result) {
| Some(Error(message)) =>
  <div className="failure">
    message->React.string
  </div>
| Some(Ok(Valid | NoValue))
| None => React.null
}
form.change
type change = (Form.field, Form.state) => unit;

This handler must be triggered onChange. It accepts field and the next form state.

module MyForm = {
  module EmailField = {
    let update = (state, value) => {...state, email: value};
  };
};

<input
  value={form.state.email}
  disabled={form.submitting}
  onBlur={_ => form.blur(Email)}
  onChange={
    event =>
      form.change(
        Email,
        MyForm.EmailField.update(
          form.state,
          event->ReactEvent.Form.target##value,
        ),
      )
  }
/>
form.blur

This handler must be triggered onBlur. It accepts only one field argument.

<input
  value={form.state.email}
  disabled={form.submitting}
  onBlur={_ => form.blur(Email)}
/>
form.submit

Use it as onSubmit handler of a <form /> element:

<form onSubmit={form.submit->Formality.Dom.preventDefault} />
form.dismissSubmissionResult

Use it when you want to let user dismissing alerts with errors from server or success message without resetting a form. Under the hood, it changes FormStatus.Submitted & FormStatus.SubmissionFailed statuses back to FormStatus.Editing.

Async validations

Some validations can't be performed locally, e.g. on signup, you want to validate if user's email is available or it's already taken.

There are 2 common ways to provide async feedback: send a request to a server on every change or only on blur event. The first way is better in terms of UX but creates a significant load, so your client might become slow or a server might feel bad. The blur way doesn't have this problem (at least not that much) but UX is definitely not the best b/c user have to blur away from a field to receive a feedback.

What can we do about it to have the best of both worlds? An answer is to debounce async validations on change. What does it mean and how does it work: when a user types something in in a form field, no external requests are triggered. Instead, it's put on hold. While user types, we wait. Once he stopped and there was no activity in the certain period—async request is triggered.

Debounced async validations on change

To implement debounced async validations you need to make some additions to common form config.

let debounceInterval: int

Configure amount of time (in ms) that Formality should wait since last user activity before invoking debounced async validation.

/* Default interval: 700 */
let debounceInterval = Formality.debounceInterval;

/* Or any other custom int */
let debounceInterval = 1000;
validator

In addition to field, strategy, dependents & validate, provide optional async validator.

type asyncValidator('field, 'state, 'message) = {
  field: 'field,
  strategy: Formality.Strategy.t,
  dependents: option(list('field)),
  validate: 'state => Result.t(ok, 'message),
  validateAsync: option(
    (
      'state => Js.Promise.t(Result.t(ok, 'message)),
      ('state, 'state) => bool,
    ),
  ),
};

validateAsync is a tuple that consists of 2 functions:

(
  /* 1. async validator */
  'state => Js.Promise.t(Result.t(ok, 'message)),

  /* 2. value equality checker */
  ('state, 'state) => bool,
)
  1. Validator itself takes state and returns Js.Promise.t(Result.t).
  2. Value equality checker receives 2 arguments: form state when validation was invoked and form state when response was resolved. Why it's required: by the time when server responded with some result, local value might be already changed so before setting received result Formality checks if value of the field is the same that was validated. And if it's not it simply ignores this result.

Please, make sure you check equality of field values, not state instances!

Example:

validateAsync: Some(
  (
    state =>
      Js.Promise.(
        state.email
        ->Api.validateEmail
        ->then_(
            valid =>
              valid ?
                Ok(Valid)->resolve :
                Error("Email is already taken")->resolve,
            _,
          )
      ),
    (prevState, nextState) => prevState.email == nextState.email,
  )
),

To create form container pass config to Formality.Async.Make functor:

module MyAsyncFormContainer = Formality.Async.Make(MyForm);

Async validations on blur

If you still want to use on blur validations just add validateAsync props to validators and use Formality.Async.MakeOnBlur to create form container.

I18n

If you build i18n'ized app then set message type in form config to your I18n.t type. E.g.:

type message = I18n.t;

Alternatives

  • ReForm
    Alternative form state management solution for ReasonReact apps.

License

MIT.