It takes 2 steps to implement a form:
- Create a form hook.
- Render a form UI.
Form hook can be created using [%form]
ppx extension. It requires at least 2 things:
input
type which must be a recordvalidators
record
Let's start with the input
:
module LoginForm = [%form
type input = {
email: string,
password: string,
};
];
As mentioned in IO section, there should be an output
type defined somewhere. If it's not provided, then under the hood it gets aliased to an input
type. So the generated code would look like this:
module LoginForm = [%form
type input = {
email: string,
password: string,
};
type output = input;
];
But since we want to deserialize form input into type-safe representation, we will provide our own output
type with the email
field set to Email.t
type.
module LoginForm = [%form
type input = {
email: string,
password: string,
};
type output = {
email: Email.t,
password: string,
};
];
Worth mentioning, fields in the output
type must be the same as in input
type. Otherwise, it would be a compile-time error.
Another important detail regarding the output
type is that you can't use external types as a value of this type. It must be a record defined in this module. I.e. this wouldn't work:
type output = LoginData.t;
One more optional type that is involved here is message
—the type of error messages that would be displayed in UI. If an app doesn't implement internalization, you can skip this type and it would be set to string
(this is what we're going to do in the current example). Otherwise, feel free to use your own type here. See I18n section for more details.
The next thing to implement is a validators
: a record with the same set of fields as in input
/output
, each holds instructions on how to validate a field. Let's implement one for email
field, assuming that somewhere in the app there is an Email
module that defines Email.t
type and Email.validate
function which takes string
and returns result(Email.t, string)
.
// Email.validate: string => result(Email.t, string)
let validators = {
email: {
strategy: OnFirstSuccessOrFirstBlur,
validate: input => input.email->Email.validate,
},
};
First of all, you don't need to define a type for validators
. It's already done by the ppx. In the simplest possible case, field validator record has 2 entries:
strategy
: as described in Validation Strategies sectionvalidate
: function that takesinput
as argument and returnsresult([OUTPUT_TYPE_OF_FIELD], message)
. In theemail
case, it'sresult(Email.t, message)
.
If field shouldn't be validated, set its validator to None
:
let validators = {
field: None,
};
Pretty much the same applies to the password
field:
let validators = {
password: {
strategy: OnFirstBlur,
validate: input =>
switch (input.password) {
| "" => Error("Password is required")
| _ => Ok(input.password)
},
},
};
Looks like we're done with the first step:
module LoginForm = [%form
type input = {
email: string,
password: string,
};
type output = {
email: Email.t,
password: string,
};
let validators = {
email: {
strategy: OnFirstSuccessOrFirstBlur,
validate: input => input.email->Email.validate,
},
password: {
strategy: OnFirstBlur,
validate: input =>
switch (input.password) {
| "" => Error("Password is required")
| _ => Ok(input.password)
},
},
};
];
The resulting module exposes the useForm
hook that we are going to use for rendering form UI.
[@react.component]
let make = () => {
let form =
LoginForm.useForm(
~initialInput={email: "", password: ""},
~onSubmit=(output, cb) => {
// Skipping this for now...
},
);
};
useForm
hook takes 2 arguments:
initialInput
: a record ofinput
type with initial field valuesonSubmit
function that takesoutput
record and one more argument with a set of callbacks. We will get back to this a bit later.
As a result, we get a form
record that holds everything we need to render UI.
Let's start with the <form />
tag:
[@react.component]
let make = () => {
let form = LoginForm.useForm(...);
<form
onSubmit={event => {
event->ReactEvent.Form.preventDefault;
form.submit();
}}
>
...
</form>
};
To trigger submission, you need to call form.submit
function. The best place to do this is onSubmit
prop of a <form />
tag. Don't forget to preventDefault
behavior to prevent page refresh on submission.
Next thing to render is a text input for email
field:
<input
value={form.input.email}
disabled={form.submitting}
onBlur={_ => form.blurEmail()}
onChange={
event =>
form.updateEmail(
(input, value) => {...input, email: value},
event->ReactEvent.Form.target##value,
)
}
/>
The value of the field is exposed via form.input
record. For extra safety, we disable all inputs during form submission using form.submitting
property which is of boolean type. The next 2 functions are very important:
form.blurEmail: unit => unit
: must be triggered fromonBlur
handler of an input fieldform.updateEmail: ((input, 'inputValue) => input, 'inputValue) => unit
: must be triggered fromonChange
handler of an input field. It takes 2 arguments:
- a function which takes 2 arguments—the current form
input
and updated input value of the current field—and returns updatedinput
record - an updated input value of the current field
The second argument—updated input value—that gets passed to the form.updateEmail
is exactly the same value as a second argument of the callback. Why it's done this way? Why not just use this value within the callback? It is designed this way to ensure that synthetic DOM event won't be captured by the callback.
// Bad
onChange={event => {
form.updateEmail(input => {
...input,
email: event->ReactEvent.Form.target##value,
});
}}
As you might already know, React's SyntheticEvent
is pooled. If you would capture the event in the callback (as shown above), since the callback gets triggered asynchronously, by the time it gets called, the event is already null'ed by React and it will result in a runtime error. To avoid this, we ensure that the value is extracted from event outside of the callback.
To display feedback in UI, we can use form.emailResult
value. It's exactly what email validator returns but wrapped in option
type:
{switch (form.emailResult) {
| Some(Error(message)) =>
<div className="error"> message->React.string </div>
| Some(Ok(_))
| None => React.null
}}
When its value is None
, it means it's not yet a good time to display any feedback to a user, according to the chosen strategy.
The same steps should be done for the password
field.
Nothing special here:
<button disabled={form.submitting}>
"Submit"->React.string
</button>
One more thing that needs to be handled is the submission of the form. When a user hits submit and the data is valid, hook triggers onSubmit
function that was passed to it.
The implementation of this handler is always app-specific. When onSubmit
handler gets triggered it receives 2 arguments: output
of the form and set of callbacks that you might want to trigger in specific circumstances or just ignore them and do your own thing according to the requirements of your app.
In general, you would want to take the output and send it to your server asynchronously. When a response is received, there might be many scenarios:
- on success, redirect a user to another screen
- on success, reset the form
- on error, show errors from the server, etc.
In this example, we would stick with the simplest one. And elaborate on more advanced scenarios in Form Submission section.
So, the scenario is:
- on success, store a user in the app state and redirect the user to another screen
- on failure, display a generic error message
Assuming, there is Api.loginUser
function in the app:
let form =
LoginForm.useForm(
~initialInput={email: "", password: ""},
~onSubmit=(output, cb) => {
output->Api.loginUser(res => switch (res) {
| Ok(user) => user->AppShell.loginAndRedirect
| Error() => cb.notifyOnFailure()
});
},
);
When submission succeeded, the user gets redirected to another screen and form gets unmounted. At this point, we don't really care about its state and just fire AppShell.loginAndRedirect
handler provided by the app (it's not specific to Formality
).
But when submission fails, we need to display an error message in UI. So we need to let form hook know about failed submission by triggering cb.notifyOnFailure()
handler passed in the second argument. What happens next?
Here, we need to mention form.status
. Form hook tracks the status of the whole form which can be in the following states:
type formStatus('submissionError) =
| Editing
| Submitting(option('submissionError))
| Submitted
| SubmissionFailed('submissionError);
When notifyOnFailure()
is triggered, form gets switched to the SubmissionFailed()
status. So you can react on this change in the UI:
switch (form.status) {
| Editing
| Submitting(_)
| Submitted => React.null
| SubmissionFailed() =>
<div className="error">
"Not logged in"->React.string
</div>
}
The whole implementation:
module LoginForm = [%form
type input = {
email: string,
password: string,
};
type output = {
email: Email.t,
password: string,
};
let validators = {
email: {
strategy: OnFirstSuccessOrFirstBlur,
validate: input => input.email->Email.validate,
},
password: {
strategy: OnFirstBlur,
validate: input =>
switch (input.password) {
| "" => Error("Password is required")
| _ => Ok(input.password)
},
},
};
];
[@react.component]
let make = () => {
let form =
LoginForm.useForm(
~initialInput={email: "", password: ""},
~onSubmit=(output, cb) => {
output->Api.loginUser(res => switch (res) {
| Ok(user) => user->AppShell.loginAndRedirect
| Error() => cb.notifyOnFailure()
});
},
);
<form onSubmit={_ => form.submit()}>
<input
value={form.input.email}
disabled={form.submitting}
onBlur={_ => form.blurEmail()}
onChange={
event =>
form.updateEmail(
(input, value) => {...input, email: value},
event->ReactEvent.Form.target##value,
)
}
/>
{switch (form.emailResult) {
| Some(Error(message)) =>
<div className="error"> message->React.string </div>
| Some(Ok(_))
| None => React.null
}}
<input
value={form.input.password}
disabled={form.submitting}
onBlur={_ => form.blurPassword()}
onChange={
event =>
form.updatePassword(
(input, value) => {...input, password: value},
event->ReactEvent.Form.target##value,
)
}
/>
{switch (form.passwordResult) {
| Some(Error(message)) =>
<div className="error"> message->React.string </div>
| Some(Ok(_))
| None => React.null
}}
<button disabled={form.submitting}>
"Submit"->React.string
</button>
{switch (form.status) {
| Editing
| Submitting(_)
| Submitted => React.null
| SubmissionFailed() =>
<div className="error">
"Not logged in"->React.string
</div>
}}
</form>
};
This is the most basic example which shows only a subset of use-cases. To find out more about advanced features, proceed to the next sections.
Next: Async Validation →