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:
inputtype which must be a recordvalidatorsrecord
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 takesinputas argument and returnsresult([OUTPUT_TYPE_OF_FIELD], message). In theemailcase, 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 ofinputtype with initial field valuesonSubmitfunction that takesoutputrecord 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 fromonBlurhandler of an input fieldform.updateEmail: ((input, 'inputValue) => input, 'inputValue) => unit: must be triggered fromonChangehandler of an input field. It takes 2 arguments:
- a function which takes 2 arguments—the current form
inputand updated input value of the current field—and returns updatedinputrecord - 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 →