Reasonable Form Handling

Form Handling in ReasonML

Introduction

When building an application that does more than just display data, there will be a need to enable user specific input at some point. This is usually achieved via forms and depending on what your app is set out to achieve, you might need many of them.

We will explore how to handle forms in ReasonML, but before we dive into an actual example, let’s take a second to think what a form should be able to achieve.

A form needs to display input fields, needs a way to validate the input data and we will probably want to display some error messages or other types of feedback. Furthermore we might want to validate dynamically or via an explicit submit. If you think about it, we might want to handle validation differently according to the situation, f.e. validate all fields once the form has been touched, or we might want to handle field inputs one by one, we might want to validate async data etc.

As we can see, there is a lot to consider when building forms. For this write-up we want to mainly focus on:

1. Validation
2. Form data management
3. Feedback based on the validated data

Now that we have a clearly defined scope we need make sure to have an up and running ReasonML setup before jump into writing code. Please refer to the official ReasonML documentation for an up-to-date setup. Further more we will use React to render the forms on to the screen. Consult the official Reason-React documentation for how to setup the complete environment.


Validation

Validation plays an important role when working with forms. Let’s try to build a validation mechanism, independent of the form itself, which receives data and returns the validation result containing error messages.

From a high level perspective we might have a record containing field names and input.

type formState = {userName: string, repeatUserName: string};
let data = {userName: "ReasonUser", repeatUserName: "Reason"}

We could define a variant containing all possible fields so we can match the fields against any existing rules.

type formField =
| UserName
| RepeatUserName;

What we will obviously also need, is a set of rules that we want to run against the provided data.

let msg = "Please provide a user name";
[(UserName, [(name => String.length(name) > 0, msg)])];

Now that we have an idea of how we might tackle the validation topic, let’s build a minimal example that receives a field, a validation function and the specified value.

type rule = (field, list((string => bool, string)));
type rules = list((field, list((string => bool, string))));

This is a generic representation of the rules as we don’t know anything about the specified data, because these depend on the specific form fields.

To get a better idea let’s try to write an initial rule.

type validation('a) =
| NotEmpty
| Custom('a);
let validate = (rule, value, values) =>
switch (rule) {
| NotEmpty => String.length(value) > 0
| Custom(fn) => fn(value, values)
};

To verify the initial implementation, let’s run a rule against a defined field and value.

validate(NotEmpty, "Reason", data);
/* test is successful: — : bool = true */
validate(NotEmpty, "", data);
/* test is successful: — : bool = false */

Check the example here.

You might be wondering why we’re passing the complete state as a third argument. Doing so, enables us to write validation functions that depend on other field values. Take the following rule set as an example.

let equalUserName = (value, values) => value === values.userName;
let emptyMsg = "Field is required";
let sameMsg = "UserName and RepeatUserName have to be same";
let rules = [
(UserName, [(NotEmpty, emptyMsg)]),
(
RepeatUserName,
[(NotEmpty, emptyMsg), (Custom(equalUserName), sameMsg)],
),
];

In the above example we created a rule, where RepeatUserName relies on UserName. From here on out we will use these rules to validate our input.

As a next step, let’s try to run an arbitrary number of data against these rules. Due to the fact that we’re working with a list of rules, we can run all the rules and check if the data is valid and return a list of error message strings.

Up until now, we haven’t defined a function that receives a field, so let’s implement a function that receives all validations and returns a list of messages for the provided field.

let validateField = (validations, value, values) =>
List.fold_left(
(errors, (fn, msg)) =>
validate(fn, value, values) ? errors : List.append(errors, [msg]),
[],
validations,
);

Again, to verify our validateField implementation, let’s run a list of rules against a defined field and value.

validateField([(NotEmpty, emptyMsg)], "Reason", data);
/* test is successful: no error messages — :- : list(string) = [] */
validateField([(NotEmpty, emptyMsg)], "", data);
/* test is successful: contains an error message — : list(string) = [“Field is required”] */

Check the example here.

Finally we will also need to run validateField for all the rules.

let validation = (rules, get, data) =>
List.fold_left(
(errors, (field, validations)) =>
{
let value = get(field, data);
validateField(validations, value, data);
}
|> (
fieldErrors =>
List.length(fieldErrors) > 0 ?
List.append(errors, [(field, fieldErrors)]) : errors
),
[],
rules,
);

validation receives all the validation functions and the current value for the field and returns a list of error messages as a list containing a field / messages tuple. The interesting part here is the expected get function. We need get to access the value from the form state. get receives a field and the form state and returns the value. On a side note: this could also be implemented as a lens for example.
Here is how get could be implemented.

let get = (field, state) =>
switch (field) {
| UserName => state.userName
| RepeatUserName => state.repeatUserName
};

Let’s run a quick test to see if everything is working as expected.

validation(rules, get, data);
/* test is successful:
[(RepeatUserName,["Field is required", "UserName and RepeatUserName have to be same"])] */

Here is the complete example.

The validation part should be working now, which means we can move on to implement our Form component.


Form

Our form component should be able to manage the local form state including errors, as well as provide functions for updating and validating the fields. What we don’t want to care about is the actual representation, as this should be definable in user land.

There is an important aspect we need to consider:

How do we wrap the form and provide the values and functions?

We will choose the render props approach, meaning we let user land define the form representation via a render prop. Further more we need to enable configuration settings f.e. form state shape etc.

Let’s create a draft.

module FormComponent = (_: config) => {
/* define fields, state, actions, field update functions etc. */
};

The state and field types as well as the get and update functions should be defined on a form specific basis, which we will accomplish via a config and expect a component back.

type t = string;
module type Config = {
type state;
type field;
let update: (field, t, state) => state;
let get: (field, state) => t;
};

type t is a string in our case, but can be defined to display different possible
types f.e. type t = | Int(int) | String(string).

Our next step is to build the FormComponent.

module FormComponent = (Config: Config) => {
type field = Config.field;
type values = Config.state;
type errors = list((field, list(string)));
type state = {
errors,
values,
};
};

Now that the needed types are defined, we can start to implement the form component with the help of the ReducerComponent provided by ReasonReact, which enables us to manage the local component state and define any necessary actions to update given state.

module FormComponent = (Config: Config) => {
type field = Config.field;
type values = Config.state;
type errors = list((field, list(string)));
type state = {
errors,
values,
};
type form = {
form: state,
handleChange: (field, t) => unit,
};
type action =
| UpdateValues(field, t);
  let component = ReasonReact.reducerComponent("FormComponent");
  let make = (~initialState, ~rules, ~render, _children) => {
...component,
initialState: () => {errors: [], values: initialState},
reducer: (action, state) =>
switch (action) {
| UpdateValues(name, value) =>
let values = Config.update(name, value, state.values);
ReasonReact.Update({
values,
errors: validation(rules, Config.get, values),
});
},
render: ({state, send}) => {
let handleChange = (field, value) => send(UpdateValues(field, value));
render({form: state, handleChange});
},
};
};

There is a lot going on here, but most of the code is React related. A ReducerComponent expects an initialState, the reducer as well as a render function. We also need to define the action type beforehand. For more information consult the ReasonReact documentation.
To keep the scope of this article manageable, we will only cover the the form state part via UpdateValues. You might also have noticed that we defined a form type, this is the record shape we will pass on to the render prop.

type form = {
form: state,
handleChange: (field, t) => unit,
};

In this example, we only pass down the form state including errors as well as handleChange for updating the field values. Depending on how the form should behave, one can extend the form shape and implement the according reducer when needed.

Let’s create a configuration to verify that everything is working as expected.

module Configuration = {
type state = formState;
type field = formField;
let update = (field, value, state) =>
switch (field) {
| UserName => {...state, userName: value}
| RepeatUserName => {...state, repeatUserName: value}
};
let get = (field, state) =>
switch (field) {
| UserName => state.userName
| RepeatUserName => state.repeatUserName
};
};

Nothing too special going on in here. We defined the needed state shape and the field, which is the previously defined formField. Our update and get pattern match against a provided field and either return a value or update the state. It should also be noted that lenses would also be a another viable approach for accessing or updating a given state, but to keep things focused we will go with this approach.

The next step is to create the actual form component.

module SpecialForm = FormComponent(Configuration);

Now we can use SpecialForm to access form state and errors inside the render prop.

let component = ReasonReact.statelessComponent("App");
let make = (~handleSubmit, _children) => {
...component,
render: _self =>
<SpecialForm
initialState={userName: "", repeatUserName: ""}
rules
render=(
({form, handleChange}) =>
<form
onSubmit=(
e => {
preventDefault(e);
handleSubmit(form.values);
}
)>
<input
value=form.values.userName
onChange=(e => getValue(e) |> handleChange(UserName))
/>
<br />
<button _type="submit"> (se("Submit")) </button>
</form>
)
/>,
};

It’s possible to update field values now but what we still need to do is display some feedback, which will be handled in the next part.


Displaying Feedback

Before we wrap up the form handling topic, we need to take care of displaying some feedback. handleChange already handles updating the specified field value as well as running the data against the rules. This means we already have the error information via the form record. If you recall, our errors have the following definition:

type errors = list((field, list(string)));

So what we probably want to do is pick the field specific errors and then either display all error messages or only display the very first one. Let’s try to extend our current implementation by adding a getErrors function that expects the field name and either returns a list of error messages or nothing. We can model this by making our result optional.

let first = list => List.length(list) > 0 ? Some(List.hd(list)) : None;
let getErrors = (field, errors) =>
List.filter(((name, _)) => name === field, errors)
|> first
|> (
errors =>
switch (errors) {
| Some((_, msgs)) => msgs
| None => []
}
);

We also created a first function a long the way to safely access the head of the list. The approach is to try to find the first tuple containing the given field name and then pattern match the result returned by our first function returning a list of message strings when any exist.
Sometimes we would only like to display the first error message. This can be accomplished by accessing the first message and returning it. Let’s extend the getErrors function to only return the first error message. While doing so, we will also rename our function getErrors to getError.

let getError = (field, errors) =>
List.filter(((name, _)) => name === field, errors)
|> first
|> (
errors =>
switch (errors) {
| Some((_, msgs)) => se(List.hd(msgs))
| None => ReasonReact.nullElement
}
);

Now we can extend our example by also displaying error messages.

module App = {
let component = ReasonReact.statelessComponent("Form");
let make = (~handleSubmit, _children) => {
...component,
render: _self =>
<SpecialForm
initialState={userName: "", repeatUserName: ""}
rules
render=(
({form, handleChange}) =>
<form
onSubmit=(
e => {
preventDefault(e);
handleSubmit(form.values);
}
)>
<label>
(se("UserName: "))
<br />
<input
value=form.values.userName
onChange=(e => getValue(e) |> handleChange(UserName))
/>
</label>
<p> (getError(UserName, form.errors)) </p>
<label>
(se("Repeat UserName: "))
<br />
<input
value=form.values.repeatUserName
onChange=(e => getValue(e) |> handleChange(RepeatUserName))
/>
</label>
<p> (getError(RepeatUserName, form.errors)) </p>
<br />
<button _type="submit"> (se("Submit")) </button>
</form>
)
/>,
};
};

We just built a basic form handling mechanism in ReasonML.
Checkout the complete example here.


Summary

While this walkthrough only touched the basics, we can already see that form handling in ReasonML can be accomplished without too many abstractions and by mainly leveraging the language features.

The good news is that there are already libraries that take care of state management and validation and the ideas we have seen in this write-up are based on said libraries. To be specific, take a look at ReForm and Formality.



If there are better ways to solve these problems or if you have any questions, please leave a comment here or on Twitter.