Type safe React components
React is awesome, and whilst https://github.com/reasonml/reason-react provides type safe bindings to the underlying React machinery, it isn't sufficient for building components which understand your own domain model, for example, a select
component that understands Customers
in my domain.
One option (excuse the pun) is to only have MySelect
understand native data structures (e.g. string
s, or integer
s):
module SelectUI = {
type t = string;
let str = ReasonReact.stringToElement;
let component = ReasonReact.statelessComponent("Select");
let make =
(
~values: array(t),
~value: option(t)=None,
_children
) => {
...component,
render: _self => {
let options =
Array.map(
x => {
let optionStart =
switch value {
| Some(y) when x == y =>
<option value=x selected=Js.true_> (str(x)) </option>
| _ => <option value=x> (str(x)) </option>
};
optionStart;
},
values
);
<select> (ReasonReact.arrayToElement(options)) </select>;
}
};
};
};
And that might be sufficient for trivial cases.
However, strings
are a little too primitive and we want to teach it how to render a non-native data type.
Well, we know that Functors are the tool for teaching something abstract about our specific types. But let's step back a step first and decide what the strategies are/where our data type will be used. A select
displays an option
for each thing
, and needs to get for each thing
:
a renderable representation (specifically a
string
)a unique
value
(specifically astring
, although aninteger
works just as well)
In addition, the component as a whole requires:
- the current
value
(if any) - a way of notifying the user which
value
was selected
We can now define the following strategy which allows the select
to ask the questions it needs to:
module type Spec = {
type t;
let toValue: t => string; /* for the `value` attribute */
let toRenderable: t => string; /* for the rendered text */
};
The component will also accept the following props:
values
: the currentarray
ofSpec.t
value
: the currently selectedSpec.t
(if any)callback
: the callback for when a new value is selected
Diversion - props or strategy?
Should the values
, value
and callback
go in the Spec
or props? I think I am settling on a rule of static
versus runtime
. The types
don't change at runtime, however the specific list of things the select
component is rendering and the currently selected value
do change at runtime. There is also a cardinality perspective; if I have a Customer
type and I want to display three different selects
each with three a different set of Customer
s, do I have three instances of the same CustomerSelect
or one instance of three different selects
? I think it is clearly the first.
It is worth pausing and noting the tension about the callback
- is that really runtime
? Well yes, I think it is given that each of the three CustomerSelect
s might have a different callback
.
A concrete component
This allows us to build our select
component (without any callback
, see the complete source code at the end):
module Select = {
module type Spec = {
type t;
let toValue: t => string;
let toRenderable: t => string;
};
module Make = (S: Spec) => {
let str = ReasonReact.stringToElement;
let component = ReasonReact.statelessComponent("Select");
let make = (~values: array(S.t), ~value: option(S.t)=None, _children) => {
...component,
render: _self => {
let options =
Array.map(
x =>
<option key=(S.toValue(x)) value=(S.toValue(x))>
(str(S.toRenderable(x)))
</option>,
values
);
switch value {
| None => <select> (ReasonReact.arrayToElement(options)) </select>
| Some(x) =>
<select value=(S.toValue(x))>
(ReasonReact.arrayToElement(options))
</select>
};
}
};
};
};
The code should now be self-explanatory.
Using the component
Assuming the following rich, realistic and well-thought out domain model:
type customer = {
a: int,
s: string
};
We would create a select
component as follows:
module MySpec: Select.Spec with type t = customer = {
type t = customer;
let toValue = x => string_of_int(x.a);
let toRenderable = x => x.s;
};
Diversion - `with type t = customer`
We run into a little gotcha if we define MySpec
as:
module MySpec: Select.Spec = {
type t = x;
...
Because ReasonML uses nominal (https://reasonml.github.io/docs/en/record.html) typing, types with the same shape are not equal. Therefore, the x
is not the same as MySpec.t
. This forces us to define the values
, value
, and callback
in the module itself as they all must be of type MySpec.t
. If they are defined outside of MySpec
then even if we strongly type them as MySpec.t they are assumed to be of type x
which causes a type error as the select
component only understands MySpec.t
and x
!= MySpec.t
.
Creating an instance of the `select` component
We can use this as follows:
/* our rich domain */
let row2: MySpec.t = {a: 2, s: "Option 2"};
let values: array(MySpec.t) = [|
{a: 1, s: "Option 1"},
row2,
{a: 3, s: "Option 3"}
|];
/* let's instantiate our `select` component */
module MySelect = UIForm.Select.Make(MySpec);
/* let's create various instances of it */
<MySelect values />
<MySelect values value=(Some(row2)) />
That's it!
Full code listing
The generic component, including a naive implementation of the callback
:
module Select = {
module type Spec = {
type t;
let toValue: t => string;
let toRenderable: t => string;
};
module Make = (S: Spec) => {
let str = ReasonReact.stringToElement;
let component = ReasonReact.statelessComponent("Select");
let make =
(~values: array(S.t), ~value: option(S.t)=None, ~callback, _children) => {
...component,
render: _self => {
let options =
Array.map(
x =>
<option key=(S.toValue(x)) value=(S.toValue(x))>
(str(S.toRenderable(x)))
</option>,
values
);
let onChange = event => {
let event =
ReactDOMRe.domElementToObj(ReactEventRe.Form.target(event));
let value = event##value;
/* some code to locate the selected item - optimise this away! */
let theT =
values
|> Array.to_list
|> List.filter(x => S.toValue(x) === value)
|> List.hd;
callback(theT);
};
switch value {
| None =>
<select onChange> (ReasonReact.arrayToElement(options)) </select>
| Some(x) =>
<select value=(S.toValue(x)) onChange>
(ReasonReact.arrayToElement(options))
</select>
};
}
};
};
};
Storybook'ing our domain:
type x = {
a: int,
s: string
};
let addSelect = () => {
let story = Storybook.createSection("Form/Select", [%bs.raw "module"]);
module MySpec: UIForm.Select.Spec with type t = x = {
type t = x;
let toValue = x => string_of_int(x.a);
let toRenderable = x => x.s;
};
module MyM = UIForm.Select.Make(MySpec);
let row2: MySpec.t = {a: 20, s: "Option 2"};
let values: array(MySpec.t) = [|
{a: 10, s: "Option 1"},
row2,
{a: 30, s: "Option 3"}
|];
let callback = x => Js.log(x);
Storybook.addStory(story, "none selected", () => <MyM values callback />);
Storybook.addStory(story, "selected", () =>
<MyM values value=(Some(row2)) callback />
);
Storybook.addStory(story, "multiple instances", () =>
<div>
<MyM values callback />
<MyM values value=(Some(row2)) callback />
</div>
);
};
let addStories = () => addSelect();