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. strings, or integers):

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:

  1. a renderable representation (specifically a string)

  2. a unique value(specifically a string, although an integer works just as well)

In addition, the component as a whole requires:

  1. the current value (if any)
  2. 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:

  1. values: the current array of Spec.t
  2. value: the currently selected Spec.t (if any)
  3. 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 Customers, 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 CustomerSelects 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.tand 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();

results matching ""

    No results matching ""