Mastodon hachyterm.io

We set up our form validation rules, tackled state management and most of the logic.

Now, we have to hook up our validation logic with the form. Each time a user types into the form, we want to update our validation rules immediately. We can then display the form validation status and give feedback to the user.

The user should only be allowed to submit the form if all rules are valid.

View Demo

The complete code is available on Github.

Validate

We have a validate function in our useForm hook, but where do we call it?

/* src/UseForm.re */

let useForm = (~formType, ~callback) => {
// previous code

let validate = (~formData=formData, ()) =>
    switch (formType) {
    | "register" =>
      formData.username->UsernameLongEnough |> dispatchRegisterFormRules;
      formData.email->EmailLongEnough |> dispatchRegisterFormRules;
      formData.email->EmailForRegistrationValid |> dispatchRegisterFormRules;
      formData.password->PasswordLongEnough |> dispatchRegisterFormRules;
    | "login" =>
      formData.email->EmailRequired |> dispatchLoginFormRules;
      formData.email->EmailForLoginValid |> dispatchLoginFormRules;
      formData.password->PasswordRequired |> dispatchLoginFormRules;
    | _ => ()
    };

// more code
};

Remember that we have two main pieces of state: our form data (username, email, password) and validation rules (including the valid status).

These two are independent from each other. But we have to synchronize them.

To update the validation state depending on the data state, we have to use a trick: useEffect.

Every time our form data updates, we let useEffect call the validate function; thus updating the form validation state.

change in `formData` --> useEffect calls `validate` --> updates `formRules`

You can read more about useEffect on the React Docs or Dan Abramov’s excellent A Complete Guide to useEffect.

Reason’s syntax for useEffect requires defining the number of dependencies:

/* src/UseForm.re */

/* global scope of the module */
React.useEffect1(                 // (A)
    () => {
      validate(~formData, ());    // (B)
      None;                       // (C)
    },
    [|formData|],                 // (A)
  );

We handle updates to formData inside its own useReducer hook - this happens every time a user types into the form.

Now, when formData changes, the useEffect1 hook (A) fires off our validate function (B). The function accepts the current formData and will check all of our validation rules. The useEffect hook performs a side-effect: we don’t need to return anything, thus adding None as the return (line C1).

As an alternative, you could derive state instead of synchronizing it.
That way, you’d only have the formData as state, and would compute the validation rules state from formData.

More State

We have to track if all rules are valid (see line A) before we allow the user to submit the form.

Instead of using useReducer, we can take a simpler approach with useState:

/* src/UseForm.re */

let useForm = (~formType, ~callback) => {
  // previous code

  let (allValid, setAllValid) = React.useState(() => false);   // (A)

  // more code

}

Reason asks you to create the initial state for the React hook with a function: a lazy initial state.
Quite cumbersome.

Let’s update our handleSubmit function:

/* src/UseForm.re */

let useForm = (~formType, ~callback) => {
  // previous code

  let handleSubmit = evt => {
      ReactEvent.Form.preventDefault(evt);
      setAllValid(_ => areAllRulesValid(~formRules));
    };

  // more code
  }

How do we know if all validation rules are valid? We have to traverse the Array of validation rules and see if they all have a valid: true flag. Here is the helper function:

/* src/UseForm.re */

/* global scope of the module */
let areAllRulesValid = (~formRules) =>
  Belt.Array.every(formRules, rule => rule.FormTypes.valid); // (A)

Belt.Array offers the familiar JavaScript methods for arrays.
Belt.Array.every is the BuckleScript equivalent of array.every:

The every() method tests whether all elements in the array pass the test implemented by the provided function.

Did you see that we have to define the type for a rule (line A)?
This is a shorthand type signature.
Instead of saying rule.FormTypes.rules.valid, we can abbreviate to rule.FormTypes.valid. Reason will check the FormTypes.re module and find the correct type.

Submit the Form

Now, if the user submits the form, we check if all rules are valid and toggle the submit status.

As another side-effect, we’ll now run the callback function for submitting the form if allValid is true. useForm received the callback function as an argument:

/* src/UseForm.re */

/* inside useForm */
let useForm = (~formType, ~callback) => {
// previous code

 React.useEffect1(
    () =>
      allValid ?
        {
          callback();
          dispatchFormData(ResetState);
          None;
        } :
        None,
    [|allValid|],
  );

  // more code
}

You can find the complete code for the custom useForm hook on Github.

Reflections

It took me a long time to get this working.

The biggest stumbling blocks were Reason’s type errors, making the form work for both “login” and “register” (code reuse/code duplication) and state management with React hooks.

On the Reason side, it was difficult to set up the logic for the two different type of forms. Reason’s type system is very strict. It didn’t allow me to use one “container” for both types of forms, so I had to work around that.
The strict typing eliminates potential bugs, but it’s hard to wrap my head around some errors.

On the React side, I had problems porting my mental model of the class-based component life-cycles to React hooks and synchronizing state.