UPDATE:
ReasonML + BuckleScript is now Rescript.
As the ecosystem has changed around those tools, this blog post is not accurate anymore.
What We’ve Done So Far
We’ve laid the groundwork for adding form validation to our app.
We defined our types; we have a simple form and a functioning useForm
custom hook in a separate module.
Create Form Rules And State Management
Let’s define our validation rules1.
/* inside UseForm.re */
let registerFormRules: FormTypes.formRules = [| // (A)
{
id: 0,
field: "username",
message: "Username must have at least 5 characters.",
valid: false,
},
{
id: 1,
field: "email",
message: "Email must have at least 5 characters.",
valid: false,
},
{
id: 2,
field: "email",
message: "Email must be a valid email address.",
valid: false,
},
{
id: 3,
field: "password",
message: "Password must have at least 10 characters.",
valid: false,
},
|];
let loginFormRules: FormTypes.formRules = [| // (A)
{id: 0, field: "email", message: "Email is required.", valid: false},
{
id: 1,
field: "email",
message: "Email must be a valid email address.",
valid: false,
},
{id: 2, field: "password", message: "Password is required.", valid: false},
|];
As mentioned in my last post, we use an Array to hold each rule. Reason’s syntax for Arrays looks strange.
In Reason, you can set up a (linked) List with square brackets: []
.
Thus, you need a different way to create an Array: square brackets with delimiters: [||]
.
You can read more about this on ReasonML’s documentation page.
Please note that we have to tell Reason the type of the form rules (see line A
). Reason can’t infer the type, as we’ve defined it in a different module:
/* src/FormTypes.re */
type formState = {
username: string,
email: string,
password: string,
};
type formRule = {
id: int,
field: string,
message: string,
valid: bool,
};
type formRules = array(formRule);
The form rules are one piece of state. We’ll have to find a way to add validation logic, and we’ll want to display the validation rules to the user.
The status of a form rule depends on what the user types into the form field. We already hold that piece of state in our useForm
custom hook inside a useReducer
(with the type FormTypes.formState
).
In my app, I created a separate useReducer
for working with the form rules. Unfortunately, that means that I have to synchronize two pieces of state (the form data from the fields and the validation rules that depend on the form data).
A better way might be to derive the state, but then you have to shove everything into one storage container instead of having two state containers.
For now, I’ll work with two distinct pieces of state, but maybe I can work out how the other approach works in a later blog post.
Inside the useForm
hook, we’ll create two new useReducers
. Why two?
One will be for our register form rules, and one for the login form rules. Reason distinguishes between those two. The compiler throws errors if you try to use them interchangeably.
/* src/UseForm.re */
let useForm = (~formType, ~callback) => {
let valueFromEvent = evt: string => evt->ReactEvent.Form.target##value;
let nameFromEvent = evt: string => evt->ReactEvent.Form.target##name;
let (formData, dispatchFormData) =
React.useReducer(formReducer, initialFormData);
+ let (registerFormRules, dispatchRegisterFormRules) =
+ React.useReducer(registerFormRulesReducer, registerFormRules); // (A)
+
+ let (loginFormRules, dispatchLoginFormRules) =
+ React.useReducer(loginFormRulesReducer, loginFormRules); // (A)
+
+ let formRules = // (B)
+ switch (formType) {
+ | "register" => registerFormRules
+ | "login" => loginFormRules
+ | _ => [||]
+ };
// same code as before
+ (formData, formRules, handleChange, handleSubmit); // (C)
}
Differentiating between those two kinds of rules (either a set of rules for login or register) proved to be complicated.
Reason requires you to be clear about different types. The rules, the dispatch function, and the action creators for a registration form are different from a login form. Although the logic is (mostly) the same, Reason doesn’t cut you slack. You have to set up two useReducers
with two distinct rules and two action creators and dispatch functions (A
).
On line B, I pattern-match on the type of form and initialize another value called formRules
, which I set to either registerFormRules
or loginFormRules
.
Bindings are immutable in Reason, but you can “overwrite” them by adding a new let
binding (which is pattern-matching under the hood, too). Read more about this at the docs.
Here we just conditionally set a binding for formRules
(similar to a variable binding in JavaScript) depending on the kind of form we receive as a parameter of the useForm
function.
Lastly, we return the formRules
(see line C
), so that a component can render them. Remember that Reason has an implicit return, so it returns the last value(s).
Reducer And Action Creators
How do these look?
/* src/UseForm.re */
type registerFormRulesAction =
| UsernameLongEnough(string)
| EmailLongEnough(string)
| EmailForRegistrationValid(string)
| PasswordLongEnough(string);
type loginFormRulesAction =
| EmailRequired(string)
| EmailForLoginValid(string)
| PasswordRequired(string);
The action creators map to their form validation rules. Each action will check on each rule.
Now, the workhorse of the logic: the reducer functions.
Again, you have to create one for each type of form.
Let’s remember how our form rules look like: its’ an Array of records where each record has a key of id, field, message, and valid.
let registerFormRules: FormTypes.formRules = [|
{
id: 0,
field: "username",
message: "Username must have at least 5 characters.",
valid: false,
},
// more rules
|];
We want to check if the input satisfies the validation rule, and then toggle the valid
key.
But we have to remember that we don’t want to mutate state directly. After each action, we want to return a new Array with all rules. If a rule is satisfied, we will change the valid
flag, but the other rules will have to remain untouched.
We must make sure that the React hooks (useState
and useReducer
) correctly handle and update state changes. We want React to re-render immediately after a rule’s valid
key was changed.
Plus, records are immutable.
Thus, we have to traverse the complete Array, pick the rule that we’re validating, replace it with a new rule with a different valid
key, and copy the rest of the Array.
Array.map
works the same as in JavaScript, but the syntax looks a tad different.
Let’s create two helper functions which will toggle the valid
key:
/* src/UseForm.re */
let setRuleToValid = (rules: FormTypes.formRules, id) =>
Array.map(
rule => rule.FormTypes.id === id ? {...rule, valid: true} : rule,
rules,
);
let setRuleToInvalid = (rules: FormTypes.formRules, id) =>
Array.map(
rule => rule.FormTypes.id === id ? {...rule, valid: false} : rule,
rules,
);
The functions take a rules Array (of type FormTypes.formRules
) and an id (of type int
which Reason infers) as input.
Then we’ll map over that array with Array.map
. The Array collection is the second argument.
The first argument is the function we use on each rule in the Array:
If the input id is the same as the id of the rule, copy it and update the valid
key, otherwise, leave it untouched.
The function would look almost the same in (functional) JavaScript:
const setRuleToValid = (rules, id) => {
return rules.map(rule => (rules.id === id ? { ...rule, valid: true } : rule))
}
Here are the two reducer functions now:
/* src/UseForm.re */
let registerFormRulesReducer =
(state: FormTypes.formRules, action: registerFormRulesAction) =>
switch (action) {
| UsernameLongEnough(username) =>
username |> String.length >= 5 ?
setRuleToValid(state, 0) : setRuleToInvalid(state, 0)
| EmailLongEnough(email) =>
email |> String.length >= 5 ?
setRuleToValid(state, 1) : setRuleToInvalid(state, 1)
| EmailForRegistrationValid(email) =>
email |> validEmail ?
setRuleToValid(state, 2) : setRuleToInvalid(state, 2)
| PasswordLongEnough(password) =>
password |> String.length >= 10 ?
setRuleToValid(state, 3) : setRuleToInvalid(state, 3)
};
let loginFormRulesReducer =
(state: FormTypes.formRules, action: loginFormRulesAction) =>
switch (action) {
| EmailRequired(email) =>
email |> String.length > 0 ?
setRuleToValid(state, 0) : setRuleToInvalid(state, 0)
| EmailForLoginValid(email) =>
email |> validateEmail ?
setRuleToValid(state, 1) : setRuleToInvalid(state, 1)
| PasswordRequired(password) =>
password |> String.length > 0 ?
setRuleToValid(state, 2) : setRuleToInvalid(state, 2)
};
Some code duplication, but I couldn’t find a better way to write this.
Each pattern-match pipes the field input into a function that checks for validity.
Here’s the helper function for a valid email that uses regular expressions1:
/* src/Form.re */
let validEmail = email => {
let re = [%bs.re
"/^(([^<>()\[\]\\.,;:\s@']+(\.[^<>()\[\]\\.,;:\s@']+)*)|('.+'))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/"
];
email |> Js.Re.test_(re);
};
I used Js.Re from the BuckleScript toolchain to test if the input matches the regular expression.
Finally, let’s wire everything together in the useForm
function:
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
};
The validate
function takes formData
(our form state: username, email, password) as the first argument.
We label that argument with the tilde ~
. (Read more about labeled arguments on the Reason documentation).
All functions are automatically curried. We now have to pass the unit type (()
) as a second argument. Every function takes at least one argument, and with labeled arguments, we have to pass unit as the second argument.
See how we make a distinction between “login” and “register”? We had to create two useReducer
hooks with separate dispatch functions.
We pattern-match on each input field and dispatch it to the applicable reducer function.
Phew, that was a lot.
Frustrations
I couldn’t find a way to decouple login and registration forms.
Now, the app holds the state for the form data, plus both the validation rules for login and register.
Perhaps I would have to extract this logic in yet another custom hook?
Plus, there’s some code duplication that I would instead want to generalize. But I’m not sure how to tackle this problem right now.
The following code is inspired by the course Microservices with Docker, Flask, and React. (I shamelessly converted the regex function for email validation 1-to-1 from JavaScript to Reason.) The course is not available anymore, as the author offers a new course on Authentication with Flask, React, and Docker. ↩︎ ↩︎