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
So far, we've created a simple ReasonReact form with a custom useForm hook and client-side routing.
The custom hook uses a useReducer
to handle state and transitions. The state is a Reason Record, a type-safe immutable "object."
We first define a type for this record, then we create the initial state:
type state = {
username: string,
email: string,
password: string,
};
let initialState = {username: "", email: "", password: ""};
The code for our custom hook looks very similar to JavaScript, but it uses some nice ReasonML features like pattern-matching.
For example, the actions and the reducer:
type action =
| SetUsername(string)
| SetEmail(string)
| SetPassword(string)
| ResetState;
let reducer = (state, action) =>
switch (action) {
| SetUsername(username) => {...state, username}
| SetEmail(email) => {...state, email}
| SetPassword(password) => {...state, password}
| ResetState => initialState
};
We're also making sure that our form targets are typed. The form events have to be strings:
let valueFromEvent = evt: string => evt->ReactEvent.Form.target##value;
let nameFromEvent = evt: string => evt->ReactEvent.Form.target##name;
While we handle the change in the forms inside useForm
, we defer what to do when we submit a form to our main component.
useForm
takes a callback function as an argument. We then define it in the Form component:
/* main form component */
[@react.component]
let make = (~formType) => {
let logger = () => Js.log("Form submitted");
let (state, handleChange, handleSubmit) = useForm(~callback=logger);
// JSX here
};
Our ReasonReact code feels similar to JavaScript React code, but it's type-safe. During development, we profited from the fast compiler, which caught our type errors.
There are some pitfalls and ugly warts, and some fundamental differences.
For example, we have to write our bindings to the events of input fields.
Every time we display some text within JSX, we have to tell Reason that the text is a string.
We can't use a dynamic object as a container for our form values. We had to set up a type beforehand, and thus are limited to using it as the structure for our form values.
Nonetheless, I wager that a React developer could read the code and be able to understand what's happening here.
Form Validation
Let's make our example app a bit more challenging and add some validation rules.
I'll admit that it took me quite some time to get it right.
The goal is to extend the custom useForm
hook to check for valid input and to display the validation status directly after the user has typed into a form field.
Extract to Different Modules
We'll create a different module for useForm
. Thus, it's also better to extract our type definitions, because we have to reference them both in the file Form.re
as well as in UseForm.re
.
Create src/FormTypes.re
:
type formState = { // (A)
username: string,
email: string,
password: string,
};
type formRule = { // (B)
id: int,
field: string,
message: string,
valid: bool,
};
type formRules = array(formRule); // (C)
Line A is copied Form.re
. But we'll rename the type from state
to formState
to be more explicit.
Then we set up the type for our validation rules (B
). We will hold all rules in an array (line C
).
We will have a fixed number of rules, and we will have to map over all of them. Arrays are a good fit because they have a fixed size. We could use a List, which is an immutable singly-linked list under the hood. But finding an element is O(n) for both Lists and Arrays, and Arrays are a better fit for sets of items of known size.
The Ocaml website offers a concise overview of the different standard containers.
React Hook Bugs
At this stage, I originally made a grave mistake.
Records are immutable by default in ReasonML. But you can create mutable record fields.
At first, I had a mutable valid
field:
type formRule = {
id: int,
field: string,
message: string,
mutable valid: bool, // Look, Ma: mutable record field!
};
The idea was to directly toggle the valid state in my form validation check. If the input of a field meets the condition, I will directly target that rule in my array like so:
/* inside UseForm.re */
let registerFormRules: FormTypes.formRules = [| // ReasonML syntax for Arrays: [||]
{ // the syntax for Lists is: []
id: 0,
field: "username",
message: "Username must have at least 5 characters.",
valid: false,
},
// more rules
|];
let registerFormRulesReducer =
(state: FormTypes.formRules, action: registerFormRulesAction) =>
switch (action) {
| UsernameLongEnough(username) =>
username |> String.length >= 5 ?
{
state[0].valid = true; // if the username field has at least 5 characters, toggle
state; // the valid field to true (mutable update)
} :
{
state[0].valid = false;
state;
}
// more form rule checks
};
I can access a rule via the Array index which is constant time (O(1)). I don't have to map over the complete data structure to target the rule I want to change.
But this approach created a nasty bug!
And this wasn't due to Reason, but to a misconception of React hooks.
I wrote about this on Thursday: don't mutate state directly - even if you do it with useReducer
.
You have to clone the Array, change it, and then pass a new Array to setState
(or dispatch a new state with useReducer
).
Don't use a mutable record if you work with React hooks!
Refactor Form.re/UseForm.re
Extract useForm
from Form.re
into a new file. Let's also rename the values and functions to be more explicit.
/* src/UseForm.re */
let initialFormData: FormTypes.formState = { // (A)
username: "",
email: "",
password: "",
};
type formAction =
| SetUsername(string)
| SetEmail(string)
| SetPassword(string)
| ResetState;
let formReducer = (state: FormTypes.formState, action: formAction) => // (A)
switch (action) {
| SetUsername(username) => {...state, username}
| SetEmail(email) => {...state, email}
| SetPassword(password) => {...state, password}
| ResetState => initialState
};
let useForm = (~formType, ~callback) => { // (B)
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 handleChange = evt => {
ReactEvent.Form.persist(evt);
switch (nameFromEvent(evt)) {
| "username" => valueFromEvent(evt)->SetUsername |> dispatchFormData
| "email" => valueFromEvent(evt)->SetEmail |> dispatchFormData
| "password" => valueFromEvent(evt)->SetPassword |> dispatchFormData
| _ => ()
};
};
let handleSubmit = evt => {
ReactEvent.Form.preventDefault(evt);
callback();
dispatch(ResetState);
};
(formData, handleChange, handleSubmit);
};
We now have to reference the type information from a different module (lines A
).
Additionally, we will need to tell useForm
the form type: "register" or "login." There will be different rules for these two forms, so we'll need to differentiate between them.
Form.re
now needs to use the correct useForm
:
/* inside Form.re */
[@react.component]
let make = (~formType) => {
let logger = () => Js.log("Form submitted");
let (state, handleChange, handleSubmit) =
UseForm.useForm(~formType, ~callback=logger); // (A)
// JSX here
};
We reference the function now with its module name and pass down the formType
props (line A
).
So far, so good. Everything should work as before, and we're now in good shape to add our validation logic.
Further Reading
- React Hooks and stale state by John Otander
Top comments (0)