Skip to content

Order with Promo

Now that you have a Promo component, you can add it to your Order component. With that in place, customers can finally enter promo codes and enjoy discounts on their orders.

Add discount type

But first, let’s see how to create a normal variant type for the discount derived variable inside Promo. We do not have to do this, because it works fine the way it is now, but the process of creating this new type should give us more insight into OCaml’s type system. Additionally, normal variants are better than polymorphic variants at “documenting” the types that will be used in your program, since they must always be explicitly defined before you can use them.

When we hover over the discount variable, we see this type expression:

re
[> `CodeError(Discount.error)
 | `Discount(float)
 | `DiscountError([> `MissingSandwichTypes
                   | `NeedMegaBurger
                   | `NeedOneBurger
                   | `NeedTwoBurgers ])
 | `NoSubmittedCode ]
[> `CodeError(Discount.error)
 | `Discount(float)
 | `DiscountError([> `MissingSandwichTypes
                   | `NeedMegaBurger
                   | `NeedOneBurger
                   | `NeedTwoBurgers ])
 | `NoSubmittedCode ]

The easiest thing to do is to create a new discount type and assign it to the above type expression, then delete the ` from the top-level variant tags to turn them into variant constructors[1]:

re
type discount =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [>
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ],
    )
  | NoSubmittedCode;
type discount =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [>
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ],
    )
  | NoSubmittedCode;

However, this results in a compilation error:

txt
Error: A type variable is unbound in this type declaration.
       In case
         DiscountError of ([> `MissingSandwichTypes
                            | `NeedMegaBurger
                            | `NeedOneBurger
                            | `NeedTwoBurgers ]
                           as 'a) the variable 'a is unbound
Error: A type variable is unbound in this type declaration.
       In case
         DiscountError of ([> `MissingSandwichTypes
                            | `NeedMegaBurger
                            | `NeedOneBurger
                            | `NeedTwoBurgers ]
                           as 'a) the variable 'a is unbound

We’ll come back to this error message later. For now, observe that the error disappears if we simply delete >:

re
type discount =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ],
    )
  | NoSubmittedCode;
type discount =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ],
    )
  | NoSubmittedCode;

This fixes the syntax error so that we now have a correctly-defined variant type.

Refactor discount

Refactor the discount derived variable inside Promo.make to use our new variant type by deleting all occurrences of `:

re
let discount =
  switch (submittedCode) {
  | None => NoSubmittedCode
  | Some(code) =>
    switch (Discount.getDiscountFunction(code, date)) {
    | Error(error) => CodeError(error)
    | Ok(discountFunction) =>
      switch (discountFunction(items)) {
      | Error(error) => DiscountError(error)
      | Ok(value) => Discount(value)
      }
    }
  };
let discount =
  switch (submittedCode) {
  | None => NoSubmittedCode
  | Some(code) =>
    switch (Discount.getDiscountFunction(code, date)) {
    | Error(error) => CodeError(error)
    | Ok(discountFunction) =>
      switch (discountFunction(items)) {
      | Error(error) => DiscountError(error)
      | Ok(value) => Discount(value)
      }
    }
  };

You can likewise refactor the switch expression inside the render logic:

re
{switch (discount) {
 | NoSubmittedCode => React.null
 | Discount(discount) => discount |> Float.neg |> RR.currency
 | CodeError(error) =>
   <div className=Style.codeError>
     {let errorType =
        switch (error) {
        | Discount.InvalidCode => "Invalid"
        | ExpiredCode => "Expired"
        };
      {j|$errorType promo code|j} |> RR.s}
   </div>
 | DiscountError(code) =>
   let buyWhat =
     switch (code) {
     | `NeedOneBurger => "at least 1 more burger"
     | `NeedTwoBurgers => "at least 2 burgers"
     | `NeedMegaBurger => "a burger with every topping"
     | `MissingSandwichTypes => "every sandwich"
     };
   <div className=Style.discountError>
     {RR.s({j|Buy $buyWhat to enjoy this promotion|j})}
   </div>;
 }}
{switch (discount) {
 | NoSubmittedCode => React.null
 | Discount(discount) => discount |> Float.neg |> RR.currency
 | CodeError(error) =>
   <div className=Style.codeError>
     {let errorType =
        switch (error) {
        | Discount.InvalidCode => "Invalid"
        | ExpiredCode => "Expired"
        };
      {j|$errorType promo code|j} |> RR.s}
   </div>
 | DiscountError(code) =>
   let buyWhat =
     switch (code) {
     | `NeedOneBurger => "at least 1 more burger"
     | `NeedTwoBurgers => "at least 2 burgers"
     | `NeedMegaBurger => "a burger with every topping"
     | `MissingSandwichTypes => "every sandwich"
     };
   <div className=Style.discountError>
     {RR.s({j|Buy $buyWhat to enjoy this promotion|j})}
   </div>;
 }}

Type constructor and type variable

Change the discount type to this:

re
type discount('a) =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError('a)
  | NoSubmittedCode;
type discount('a) =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError('a)
  | NoSubmittedCode;

Now discount is a type constructor that takes a type variable named 'a. A type constructor is not a fixed type—you can think of it as a function that takes a type and outputs a new type.

The advantage of doing this is that the variant tags inside DiscountError are no longer constrained by our discount type. This makes sense because they are used primarily in the Discount module, and if any variant tags are renamed, added, or deleted, those changes will and should happen in Discount.

Using a type variable does not sacrifice type safety, if you hover over the discount variable, you see that its type is:

reason
discount([> `MissingSandwichTypes
          | `NeedMegaBurger
          | `NeedOneBurger
          | `NeedTwoBurgers ])
discount([> `MissingSandwichTypes
          | `NeedMegaBurger
          | `NeedOneBurger
          | `NeedTwoBurgers ])

Based on its usage, OCaml can figure out the exact type of the discount variable and automatically fill in the value of the type variable.

> = “allow more than”

In the type expression above, we once again see >, so let’s see what it means. In polymorphic variant type expressions, it means “allow more than”. In this case, it means that tags other than the four that are listed are allowed. For example, this type would be allowed:

reason
discount([| `MissingSandwichTypes
          | `NeedMegaBurger
          | `NeedOneBurger
          | `NeedTwoBurgers
          | `HoneyButter
          | `KewpieMayo ])
discount([| `MissingSandwichTypes
          | `NeedMegaBurger
          | `NeedOneBurger
          | `NeedTwoBurgers
          | `HoneyButter
          | `KewpieMayo ])

When defining your own types, you will most often used fixed polymormorphic variants, i.e. those that don’t have > in their type expressions. But it is still useful to know what > does, since it appears when the compiler infers the type of a variable or function that uses polymorphic variants.

TIP

Fixed polymorphic variants and normal variants are roughly equivalent and can be used interchangeably.

Implicit type variable

Let’s come back to the question of why the original attempt at a variant type definition was syntactically invalid:

re
type discount =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [>
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ],
    )
  | NoSubmittedCode;
type discount =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [>
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ],
    )
  | NoSubmittedCode;

The reason is that there’s an implicit type variable around the >. The above type expression is equivalent to:

re
type discount =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [>
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ] as 'a,
    )
  | NoSubmittedCode;
type discount =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [>
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ] as 'a,
    )
  | NoSubmittedCode;

Now the error message makes a bit more sense:

txt
Error: A type variable is unbound in this type declaration.
       In case
         DiscountError of ([> `MissingSandwichTypes
                            | `NeedMegaBurger
                            | `NeedOneBurger
                            | `NeedTwoBurgers ]
                           as 'a) the variable 'a is unbound
Error: A type variable is unbound in this type declaration.
       In case
         DiscountError of ([> `MissingSandwichTypes
                            | `NeedMegaBurger
                            | `NeedOneBurger
                            | `NeedTwoBurgers ]
                           as 'a) the variable 'a is unbound

The type variable exists, but it’s pointless unless it appears as an argument of the discount type constructor. Once it’s added, it compiles:

re
type discount('a) =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [>
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ] as 'a,
    )
  | NoSubmittedCode;
type discount('a) =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError(
      [>
        | `MissingSandwichTypes
        | `NeedMegaBurger
        | `NeedOneBurger
        | `NeedTwoBurgers
      ] as 'a,
    )
  | NoSubmittedCode;

This is somewhat like accidentally using a variable in a function but forgetting to add that variable to the function’s argument list.

Force DiscountError argument to be polymorphic variant

Right now the argument of the DiscountError constructor can be any type at all, but to be explicit, we can force it to be a polymorphic variant:

re
type discount('a) =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError([> ] as 'a)
  | NoSubmittedCode;
type discount('a) =
  | CodeError(Discount.error)
  | Discount(float)
  | DiscountError([> ] as 'a)
  | NoSubmittedCode;

The [> ] type expression means a polymorphic variant that has no tags, but allows more tags, which basically means any polymorphic variant. Note that adding this small restriction to the type doesn’t make a real difference in this program—it’s just a way to make it clear that DiscountError’s argument should be a polymorphic variant. It’s an optional embellishment that you can feel free to leave out.

Quick summary

You refactored the discount reactive variable to use a normal variant instead of a polymorphic variant. The code changes were fairly minimal, but to understand what was happening, it was necessary to learn the basics of type constructors and type variables. In the next sections, we’ll set types and other theoretical considerations aside and get into the nitty-gritty of the UI changes you must make to add promo support to the Order component.

Add DateInput component

To see different promotions in action, we want to be able to easily change the date in our demo, so add a new file DateInput.re:

re
let stringToDate = s =>
  // add "T00:00" to make sure the date is in local time
  s ++ "T00:00" |> Js.Date.fromString;

let dateToString = d =>
  Printf.sprintf(
    "%4.0f-%02.0f-%02.0f",
    Js.Date.getFullYear(d),
    Js.Date.getMonth(d) +. 1.,
    Js.Date.getDate(d),
  );

[@react.component]
let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => {
  <input
    type_="date"
    required=true
    value={dateToString(date)}
    onChange={evt => evt |> RR.getValueFromEvent |> stringToDate |> onChange}
  />;
};
let stringToDate = s =>
  // add "T00:00" to make sure the date is in local time
  s ++ "T00:00" |> Js.Date.fromString;

let dateToString = d =>
  Printf.sprintf(
    "%4.0f-%02.0f-%02.0f",
    Js.Date.getFullYear(d),
    Js.Date.getMonth(d) +. 1.,
    Js.Date.getDate(d),
  );

[@react.component]
let make = (~date: Js.Date.t, ~onChange: Js.Date.t => unit) => {
  <input
    type_="date"
    required=true
    value={dateToString(date)}
    onChange={evt => evt |> RR.getValueFromEvent |> stringToDate |> onChange}
  />;
};

A few notes:

  • We use Printf.sprintf to give us more control over how the float components of a Date[2] are converted to strings:
    • The float conversion specification%4.0f sets a minimum width of 4 and 0 numbers after the decimal
    • The float conversion specification %02.0f sets a minimum width of 2 (left padded with 0) and 0 numbers after the decimal
  • The type prop of input has been renamed to type_, because in OCaml, type is a reserved keyword and can’t be used as an argument name. But don’t worry, it will still say type in the generated JS output.

Add Demo component

Move the contents of Index.App into a new file called Demo.re. In the process, add our newly-created DateInput component:

re
let items: Order.t = [
  Sandwich(Portabello),
  Sandwich(Unicorn),
  Sandwich(Ham),
  Sandwich(Turducken),
  Hotdog,
  Burger({lettuce: true, tomatoes: true, onions: 3, cheese: 2, bacon: 6}),
  Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 0, bacon: 0}),
  Burger({lettuce: true, tomatoes: false, onions: 1, cheese: 1, bacon: 1}),
  Burger({lettuce: false, tomatoes: false, onions: 1, cheese: 0, bacon: 0}),
  Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 1, bacon: 0}),
];

[@react.component]
let make = () => {
  let (date, setDate) =
    RR.useStateValue(Js.Date.fromString("2024-05-28T00:00"));

  <div>
    <h1> {RR.s("Order confirmation")} </h1>
    <DateInput date onChange=setDate />
    <h2> {RR.s("Order")} </h2>
    <Order items date />
  </div>;
};
let items: Order.t = [
  Sandwich(Portabello),
  Sandwich(Unicorn),
  Sandwich(Ham),
  Sandwich(Turducken),
  Hotdog,
  Burger({lettuce: true, tomatoes: true, onions: 3, cheese: 2, bacon: 6}),
  Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 0, bacon: 0}),
  Burger({lettuce: true, tomatoes: false, onions: 1, cheese: 1, bacon: 1}),
  Burger({lettuce: false, tomatoes: false, onions: 1, cheese: 0, bacon: 0}),
  Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 1, bacon: 0}),
];

[@react.component]
let make = () => {
  let (date, setDate) =
    RR.useStateValue(Js.Date.fromString("2024-05-28T00:00"));

  <div>
    <h1> {RR.s("Order confirmation")} </h1>
    <DateInput date onChange=setDate />
    <h2> {RR.s("Order")} </h2>
    <Order items date />
  </div>;
};

Change Index to use the new Demo component:

re
let node = ReactDOM.querySelector("#root");
switch (node) {
| None =>
  Js.Console.error("Failed to start React: couldn't find the #root element")
| Some(root) =>
  let root = ReactDOM.Client.createRoot(root);
  ReactDOM.Client.render(root, <Demo />);
};
let node = ReactDOM.querySelector("#root");
switch (node) {
| None =>
  Js.Console.error("Failed to start React: couldn't find the #root element")
| Some(root) =>
  let root = ReactDOM.Client.createRoot(root);
  ReactDOM.Client.render(root, <Demo />);
};

Add Promo to Order

Add the Promo component to the Order component:

re
[@react.component]
let make = (~items: t, ~date: Js.Date.t) => {
  let (discount, setDiscount) = RR.useStateValue(0.0);

  let subtotal =
    items
    |> ListLabels.fold_left(~init=0., ~f=(acc, order) =>
         acc +. Item.toPrice(order, ~date)
       );

  <table className=Style.order>
    <tbody>
      {items
       |> List.mapi((index, item) =>
            <OrderItem key={"item-" ++ string_of_int(index)} item />
          )
       |> RR.list}
      <tr className=Style.total>
        <td> {RR.s("Subtotal")} </td>
        <td> {subtotal |> RR.currency} </td>
      </tr>
      <tr>
        <td> {RR.s("Promo code")} </td>
        <td> <Promo items date onApply=setDiscount /> </td>
      </tr>
      <tr className=Style.total>
        <td> {RR.s("Total")} </td>
        <td> {subtotal -. discount |> RR.currency} </td>
      </tr>
    </tbody>
  </table>;
};
[@react.component]
let make = (~items: t, ~date: Js.Date.t) => {
  let (discount, setDiscount) = RR.useStateValue(0.0);

  let subtotal =
    items
    |> ListLabels.fold_left(~init=0., ~f=(acc, order) =>
         acc +. Item.toPrice(order, ~date)
       );

  <table className=Style.order>
    <tbody>
      {items
       |> List.mapi((index, item) =>
            <OrderItem key={"item-" ++ string_of_int(index)} item />
          )
       |> RR.list}
      <tr className=Style.total>
        <td> {RR.s("Subtotal")} </td>
        <td> {subtotal |> RR.currency} </td>
      </tr>
      <tr>
        <td> {RR.s("Promo code")} </td>
        <td> <Promo items date onApply=setDiscount /> </td>
      </tr>
      <tr className=Style.total>
        <td> {RR.s("Total")} </td>
        <td> {subtotal -. discount |> RR.currency} </td>
      </tr>
    </tbody>
  </table>;
};

A breakdown:

  • Create a new state variable called discount (along with its attendant setDiscount function)
  • Set the value of discount through Promo’s onApply callback prop (we’ll add this prop in the next step)
  • Subtract discount from subtotal when rendering the total price of the order

Add onApply prop to Promo

Add an onApply prop to Promo, which will be invoked when a promo code is successfully submitted and results in a discount:

re
[@react.component]
let make =
    (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) =>
[@react.component]
let make =
    (~items: list(Item.t), ~date: Js.Date.t, ~onApply: float => unit) =>

TIP

You don’t have to type annotate your component props, but it’s a good idea to at least type annotate your component’s callback props as a form of documentation.

To invoke onApply, we can add a useEffect hook that invokes onApply when discount has a value of the form `Discount(value):

re
React.useEffect1(
  () => {
    switch (discount) {
    | NoSubmittedCode
    | CodeError(_)
    | DiscountError(_) => ()
    | Discount(value) => onApply(value)
    };
    None;
  },
  [|discount|],
);
React.useEffect1(
  () => {
    switch (discount) {
    | NoSubmittedCode
    | CodeError(_)
    | DiscountError(_) => ()
    | Discount(value) => onApply(value)
    };
    None;
  },
  [|discount|],
);

Note that when discount has an error value, we return () from the switch expression, which essentially means “do nothing”.

React.useEffect* functions

React.useEffect1 is one of the binding functions for React’s useEffect hook. The number 1 at the end of the function indicates how many dependencies this function is supposed to take. Accordingly, we also have React.useEffect0, React.useEffect2, etc, all the way up to React.useEffect7[3].

All React.useEffect* functions accept a setup callback as their first argument, the type of which is:

reason
unit => option(unit => unit)
unit => option(unit => unit)

The setup callback’s return type is option(unit => unit), which allows you to return a cleanup function encased in Some. When the effect doesn’t need a cleanup function, just return None.

The second argument for all React.useEffect* functions (except React.useEffect0) is for the dependencies. For example, the type of React.useEffect2 is:

reason
(unit => option(unit => unit), ('a, 'b)) => unit
(unit => option(unit => unit), ('a, 'b)) => unit

And the type of React.useEffect3 is:

reason
(unit => option(unit => unit), ('a, 'b, 'c)) => unit
(unit => option(unit => unit), ('a, 'b, 'c)) => unit

TIP

Every time you add or remove a dependency from your useEffect hook, you’ll need to use a different React.useEffect* function (the one that corresponds to how many dependencies you now have).

Why does React.useEffect2 accept a tuple?

React.useEffect2 takes its dependencies as a tuple instead of an array. To understand why, we need to understand the type properties of tuples and arrays:

  • The elements of tuples can have different types, e.g. (1, "a", 23.5)
  • The elements of arrays must all be of the same type, e.g. [|1, 2, 3|], [|"a", "b", "c"|]

Therefore, we must use tuples to express the dependencies of useEffect hooks, otherwise our dependencies would all have to be of the same type. This applies to all React.useEffect* functions which take 2 or more dependencies.

Even though we use tuples for dependencies in our OCaml code, they are turned into JS arrays in the runtime. So the generated code will run the same as in any ReactJS app.

However, you might have noticed that React.useEffect1 is the odd man out, because it accepts an array for its single dependency. The reason is that one-element OCaml tuples don’t become arrays in the JS runtime, they instead take on the value of their single element. So in this case, React.useEffect1must take an array so that it respects the API of the underlying useEffect function.

RR.useEffect1 helper function

React.useEffect1 taking an array means that you could accidentally pass in an empty array as the dependency for React.useEffect1. You can sidestep this possibility by adding a helper function to your RR module:

re
/** Helper for [React.useEffect1] */
let useEffect1 = (func, dep) => React.useEffect1(func, [|dep|]);
/** Helper for [React.useEffect1] */
let useEffect1 = (func, dep) => React.useEffect1(func, [|dep|]);

Refactor Promo to use your new helper function:

re
RR.useEffect1(
  () => {
    switch (discount) {
    | NoSubmittedCode
    | CodeError(_)
    | DiscountError(_) => ()
    | Discount(value) => onApply(value)
    };
    None;
  },
  discount,
);
RR.useEffect1(
  () => {
    switch (discount) {
    | NoSubmittedCode
    | CodeError(_)
    | DiscountError(_) => ()
    | Discount(value) => onApply(value)
    };
    None;
  },
  discount,
);

You may be wondering why ReasonReact doesn’t provide this helper function for you. The reason is that its bindings to React functions are supposed to be zero-cost, without any additional abstractions on top. This is the same reason that something like our RR.useStateValue helper function is also not included with ReasonReact.

Add styling for promo code row

Execute npm run serve to see your app in action. Verify that it behaves as expected:

  • Type FREE into the input and press Enter. It should deduct the price of every other burger (ordered by price descending).
  • Type HALF into the input and press Enter. It should deduct half off the entire order.
  • Change the date to something other than May 28. It should show an error saying “Expired promo code”

However, the styling is a little bit off. Add the following value to Order.Style:

re
let promo = [%cx
  {|
  border-top: 1px solid gray;
  text-align: right;
  vertical-align: top;
  |}
];
let promo = [%cx
  {|
  border-top: 1px solid gray;
  text-align: right;
  vertical-align: top;
  |}
];

Then set the className of the promo code row to Style.promo:

re
<tr className=Style.promo>
  <td> {RR.s("Promo code")} </td>
  <td> <Promo items date onApply=setDiscount /> </td>
</tr>
<tr className=Style.promo>
  <td> {RR.s("Promo code")} </td>
  <td> <Promo items date onApply=setDiscount /> </td>
</tr>

How often does the Effect run?

Everything seems to be working correctly, but let’s see how often our useEffect hook fires by adding a little logging:

re
RR.useEffect1(
  () => {
    switch (discount) {
    | NoSubmittedCode
    | CodeError(_)
    | DiscountError(_) => ()
    | Discount(value) =>
      Js.log2("useEffect1 depending on discount", value);
      onApply(value);
    };
    None;
  },
  discount,
);
RR.useEffect1(
  () => {
    switch (discount) {
    | NoSubmittedCode
    | CodeError(_)
    | DiscountError(_) => ()
    | Discount(value) =>
      Js.log2("useEffect1 depending on discount", value);
      onApply(value);
    };
    None;
  },
  discount,
);

You see that every time a promo code is successfully applied, it logs twice to the console. That doesn’t seem right, because the value of discount only changes once when you submit a new promo code.

The reason lies in the runtime representation of discount—recall that variant constructors with arguments are turned into objects in the JS runtime. Because discount is a derived variable, it gets recreated on every render, and even if its contents didn’t change, the hook will always treat it as having changed because the object is no longer the same one as before.

The easiest fix is to simply change the dependency to submittedCode instead of discount:

re
RR.useEffect1(
  () => {
    switch (discount) {
    | NoSubmittedCode
    | CodeError(_)
    | DiscountError(_) => ()
    | Discount(value) =>
      Js.log2("useEffect1 depending on discount", value);
      onApply(value);
    };
    None;
  },
  submittedCode,
);
RR.useEffect1(
  () => {
    switch (discount) {
    | NoSubmittedCode
    | CodeError(_)
    | DiscountError(_) => ()
    | Discount(value) =>
      Js.log2("useEffect1 depending on discount", value);
      onApply(value);
    };
    None;
  },
  submittedCode,
);

This does the trick—the Effect only runs once every time you submit a new promo code. But wait! Why does it behave differently when submittedCode is an option, and option is just another variant type?[4]

Although option is a variant type, its runtime representation is a special case:

  • None becomes undefined
  • Some(value) becomes value

Therefore, an option value that wraps a primitive value doesn’t ever turn into an object in the JS runtime, and therefore can be used as a dependency for React hooks.

You don’t need an Effect

The above discussion about Effects was somewhat academic, because we don’t actually need Effects to handle user events. Let’s delete the call to RR.useEffect1 and start over.

A better place to call onApply is from within the form’s onSubmit callback. Replace the discount derived variable with a getDiscount function:

re
let getDiscount =
  fun
  | None => NoSubmittedCode
  | Some(code) =>
    switch (Discount.getDiscountFunction(code, date)) {
    | Error(error) => CodeError(error)
    | Ok(discountFunc) =>
      switch (discountFunc(items)) {
      | Error(error) => DiscountError(error)
      | Ok(value) => Discount(value)
      }
    };
let getDiscount =
  fun
  | None => NoSubmittedCode
  | Some(code) =>
    switch (Discount.getDiscountFunction(code, date)) {
    | Error(error) => CodeError(error)
    | Ok(discountFunc) =>
      switch (discountFunc(items)) {
      | Error(error) => DiscountError(error)
      | Ok(value) => Discount(value)
      }
    };

Call getDiscount within the onSubmit callback function:

re
onSubmit={evt => {
  evt |> React.Event.Form.preventDefault;
  let newSubmittedCode = Some(code);
  setSubmittedCode(newSubmittedCode);
  switch (getDiscount(newSubmittedCode)) {
  | NoSubmittedCode
  | CodeError(_)
  | DiscountError(_) => ()
  | Discount(value) => onApply(value)
  };
}}
onSubmit={evt => {
  evt |> React.Event.Form.preventDefault;
  let newSubmittedCode = Some(code);
  setSubmittedCode(newSubmittedCode);
  switch (getDiscount(newSubmittedCode)) {
  | NoSubmittedCode
  | CodeError(_)
  | DiscountError(_) => ()
  | Discount(value) => onApply(value)
  };
}}

Inside the render logic, change the input of the switch expression from discount to getDiscount(submittedCode):

reason
{switch (discount) { 
{switch (getDiscount(submittedCode)) { 
{switch (discount) { 
{switch (getDiscount(submittedCode)) { 

Add datasets to Demo

To make it easier to see the different promo-related error messages, you can create different collections of items. Add a datasets variable to Demo:

re
let burger =
  Item.Burger.{
    lettuce: false,
    tomatoes: false,
    onions: 0,
    cheese: 0,
    bacon: 0,
  };

let datasets = {
  [
    (
      "No burgers",
      Item.[
        Sandwich(Unicorn),
        Hotdog,
        Sandwich(Ham),
        Sandwich(Turducken),
        Hotdog,
      ],
    ),
    (
      "5 burgers",
      {
        [
          Burger({...burger, tomatoes: true}),
          Burger({...burger, lettuce: true}),
          Burger({...burger, bacon: 2}),
          Burger({...burger, cheese: 3, onions: 9, tomatoes: true}),
          Burger({...burger, onions: 2}),
        ];
      },
    ),
    (
      "1 burger with at least one of every topping",
      [
        Hotdog,
        Burger({
          lettuce: true,
          tomatoes: true,
          onions: 1,
          cheese: 2,
          bacon: 3,
        }),
        Sandwich(Turducken),
      ],
    ),
    (
      "All sandwiches",
      [
        Sandwich(Ham),
        Hotdog,
        Sandwich(Portabello),
        Sandwich(Unicorn),
        Hotdog,
        Sandwich(Turducken),
      ],
    ),
  ];
};
let burger =
  Item.Burger.{
    lettuce: false,
    tomatoes: false,
    onions: 0,
    cheese: 0,
    bacon: 0,
  };

let datasets = {
  [
    (
      "No burgers",
      Item.[
        Sandwich(Unicorn),
        Hotdog,
        Sandwich(Ham),
        Sandwich(Turducken),
        Hotdog,
      ],
    ),
    (
      "5 burgers",
      {
        [
          Burger({...burger, tomatoes: true}),
          Burger({...burger, lettuce: true}),
          Burger({...burger, bacon: 2}),
          Burger({...burger, cheese: 3, onions: 9, tomatoes: true}),
          Burger({...burger, onions: 2}),
        ];
      },
    ),
    (
      "1 burger with at least one of every topping",
      [
        Hotdog,
        Burger({
          lettuce: true,
          tomatoes: true,
          onions: 1,
          cheese: 2,
          bacon: 3,
        }),
        Sandwich(Turducken),
      ],
    ),
    (
      "All sandwiches",
      [
        Sandwich(Ham),
        Hotdog,
        Sandwich(Portabello),
        Sandwich(Unicorn),
        Hotdog,
        Sandwich(Turducken),
      ],
    ),
  ];
};

Since the burgers value is only used in a single expression, we can move it inside that expression:

re
{
  let burger =
    Item.Burger.{
      lettuce: false,
      tomatoes: false,
      onions: 0,
      cheese: 0,
      bacon: 0,
    };
  (
    "5 burgers",
    {
      [
        Burger({...burger, tomatoes: true}),
        Burger({...burger, lettuce: true}),
        Burger({...burger, bacon: 2}),
        Burger({...burger, cheese: 3, onions: 9, tomatoes: true}),
        Burger({...burger, onions: 2}),
      ];
    },
  );
},
{
  let burger =
    Item.Burger.{
      lettuce: false,
      tomatoes: false,
      onions: 0,
      cheese: 0,
      bacon: 0,
    };
  (
    "5 burgers",
    {
      [
        Burger({...burger, tomatoes: true}),
        Burger({...burger, lettuce: true}),
        Burger({...burger, bacon: 2}),
        Burger({...burger, cheese: 3, onions: 9, tomatoes: true}),
        Burger({...burger, onions: 2}),
      ];
    },
  );
},

TIP

OCaml makes it easy to move variable definitions closer to where they are actually used. Unlike in JavaScript, you can use let anywhere, even inside an expression.

Now we can refactor Demo to render a different Order for each collection of items:

re
[@react.component]
let make = () => {
  let (date, setDate) =
    RR.useStateValue(Js.Date.fromString("2024-05-28T00:00"));

  <div>
    <h1> {RR.s("Order Confirmation")} </h1>
    <DateInput date onChange=setDate />
    <h2> {RR.s("Order")} </h2>
    {datasets
     |> List.map(((label, items)) => {
          <div key=label> <h3> {RR.s(label)} </h3> <Order items date /> </div>
        })
     |> RR.list}
  </div>;
};
[@react.component]
let make = () => {
  let (date, setDate) =
    RR.useStateValue(Js.Date.fromString("2024-05-28T00:00"));

  <div>
    <h1> {RR.s("Order Confirmation")} </h1>
    <DateInput date onChange=setDate />
    <h2> {RR.s("Order")} </h2>
    {datasets
     |> List.map(((label, items)) => {
          <div key=label> <h3> {RR.s(label)} </h3> <Order items date /> </div>
        })
     |> RR.list}
  </div>;
};

Remember to delete the now-unused Demo.items variable.


Hot diggity! You’ve added the promo codes to your order confirmation widget, just in time for Madame Jellobutter’s International Burger Day promotions. In the next chapter, we’ll further polish the sandwich promotion logic.

Overview

  • A type constructor takes a type and outputs another type
  • A type variable is a variable that stands in a for a type and often appears in type constructors or type signatures
  • In polymorphic variant type expressions, > means that the polymorphic variant can accept more than the variant tags that are listed
    • You rarely need to use > in your own type definitions, but it often appears in inferred type definitions (that appear when you hover over variables and functions)
    • Inferred type definitions that contain > also have an implicit type variable
  • Some component props have names that aren’t legal as function arguments in OCaml, and we must add an underscore after them. A common example is type, which must be rewritten as type_[5].
  • ReasonReact has several binding functions for React’s useEffect hook, e.g. React.useEffect0, React.useEffect1, …, React.useEffect7
    • The number at the end indicates how many dependencies the function takes
    • React.useEffect1 takes an array for its one dependency
    • React.useEffect2 and above take tuples for their dependencies
  • The elements of a tuple can be different types
  • Tuples become arrays in the JavaScript runtime
  • The elements of an array must all be the same type
  • Be careful about using variants as hook dependencies, because they often get turned into objects in the runtime and cause Effects to run more often than you want
  • It’s often safe to use option as a hook dependency, because even though it’s a variant, it’s a special case and does not become an object in the JavaScript runtime
  • You can use let inside expressions, which allows you to define variables closer to where they’re used

Exercises

1. The following code (playground) doesn’t compile. Fix it by adding a single character.

reason
let getName = (animal: [| `Cat | `Dog(int) | `Unicorn(string)]) =>
  switch (animal) {
  | `Cat => "Mr Whiskers"
  | `Dog(n) => "Bandit " ++ string_of_int(n)
  | `Unicorn(name) => "Sir " ++ name
  };
let getName = (animal: [| `Cat | `Dog(int) | `Unicorn(string)]) =>
  switch (animal) {
  | `Cat => "Mr Whiskers"
  | `Dog(n) => "Bandit " ++ string_of_int(n)
  | `Unicorn(name) => "Sir " ++ name
  };
Hint

Find a place where you can insert a space.

Solution
reason
let getName = (animal: [ | `Cat | `Dog(int) | `Unicorn(string)]) =>
  switch (animal) {
  | `Cat => "Mr Whiskers"
  | `Dog(n) => "Bandit " ++ string_of_int(n)
  | `Unicorn(name) => "Sir " ++ name
  };
let getName = (animal: [ | `Cat | `Dog(int) | `Unicorn(string)]) =>
  switch (animal) {
  | `Cat => "Mr Whiskers"
  | `Dog(n) => "Bandit " ++ string_of_int(n)
  | `Unicorn(name) => "Sir " ++ name
  };

A common mistake when writing polymorphic variant type definitions is forgetting to put a space between the [ and the | characters. Note that you don’t need to add the implicit type variable in type annotations.

WARNING

In the next version of Melange, polymorphic variant definitions no longer require a space between [ and |.

2. The following code (playground) doesn’t compile. Fix it by adding a single character.

reason
let getName = (animal: [| `Cat | `Dog(int) | `Unicorn(string)]) =>
  switch (animal) {
  | `Cat => "Mr Whiskers"
  | `Dog(n) => "Bandit " ++ string_of_int(n)
  | `Unicorn(name) => "Sir " ++ name
  | `Dragon => "Puff the Magic"
  };
let getName = (animal: [| `Cat | `Dog(int) | `Unicorn(string)]) =>
  switch (animal) {
  | `Cat => "Mr Whiskers"
  | `Dog(n) => "Bandit " ++ string_of_int(n)
  | `Unicorn(name) => "Sir " ++ name
  | `Dragon => "Puff the Magic"
  };
Solution
reason
let getName = (animal: [> | `Cat | `Dog(int) | `Unicorn(string)]) =>
  switch (animal) {
  | `Cat => "Mr Whiskers"
  | `Dog(n) => "Bandit " ++ string_of_int(n)
  | `Unicorn(name) => "Sir " ++ name
  | `Dragon => "Puff the Magic"
  };
let getName = (animal: [> | `Cat | `Dog(int) | `Unicorn(string)]) =>
  switch (animal) {
  | `Cat => "Mr Whiskers"
  | `Dog(n) => "Bandit " ++ string_of_int(n)
  | `Unicorn(name) => "Sir " ++ name
  | `Dragon => "Puff the Magic"
  };

Adding a > to the polymorphic variant type definition allows it to accept more than the listed variant tags.

3. Fix the following code (playground) which fails to compile:

reason
/** Only invoke [f] when [o1] and [o2] are [Some] */
let map2: (option('a), option('a), ('a, 'a) => 'a) => option('a) =
  (o1, o2, f) =>
    switch (o1, o2) {
    | (None, None)
    | (None, Some(_))
    | (Some(_), None) => None
    | (Some(v1), Some(v2)) => Some(f(v1, v2))
    };

Js.log(map2(Some(11), Some(33), (+)));
Js.log(map2(Some("ABC"), Some(123), (a, b) => (a, b)));
/** Only invoke [f] when [o1] and [o2] are [Some] */
let map2: (option('a), option('a), ('a, 'a) => 'a) => option('a) =
  (o1, o2, f) =>
    switch (o1, o2) {
    | (None, None)
    | (None, Some(_))
    | (Some(_), None) => None
    | (Some(v1), Some(v2)) => Some(f(v1, v2))
    };

Js.log(map2(Some(11), Some(33), (+)));
Js.log(map2(Some("ABC"), Some(123), (a, b) => (a, b)));
Hint 1

Fix the type annotation.

Hint 2

Delete the type annotation.

Solution
reason
/** Only invoke [f] when [o1] and [o2] are [Some] */
let map2: (option('a), option('b), ('a, 'b) => 'c) => option('c) =
  (o1, o2, f) =>
    switch (o1, o2) {
    | (None, None)
    | (None, Some(_))
    | (Some(_), None) => None
    | (Some(v1), Some(v2)) => Some(f(v1, v2))
    };

Js.log(map2(Some(11), Some(33), (+)));
Js.log(map2(Some("ABC"), Some(123), (a, b) => (a, b)));
/** Only invoke [f] when [o1] and [o2] are [Some] */
let map2: (option('a), option('b), ('a, 'b) => 'c) => option('c) =
  (o1, o2, f) =>
    switch (o1, o2) {
    | (None, None)
    | (None, Some(_))
    | (Some(_), None) => None
    | (Some(v1), Some(v2)) => Some(f(v1, v2))
    };

Js.log(map2(Some(11), Some(33), (+)));
Js.log(map2(Some("ABC"), Some(123), (a, b) => (a, b)));

We have to use different type variables if we expect that the types might be different. Note that we could have deleted the type annotation and then OCaml’s inferred type would be the same as the type annotation above.

4. Change the render logic so that a DateInput is rendered above each Order. Changing the date on a DateInput changes the date for the Order below it.

Hint

Define a DateAndOrder helper component.

Solution

Add Demo.DateAndOrder subcomponent:

re
module DateAndOrder = {
  [@react.component]
  let make = (~label: string, ~items: list(Item.t)) => {
    let (date, setDate) =
      RR.useStateValue(Js.Date.fromString("2024-05-28T00:00"));

    <div>
      <h2> {RR.s(label)} </h2>
      <DateInput date onChange=setDate />
      <Order items date />
    </div>;
  };
};
module DateAndOrder = {
  [@react.component]
  let make = (~label: string, ~items: list(Item.t)) => {
    let (date, setDate) =
      RR.useStateValue(Js.Date.fromString("2024-05-28T00:00"));

    <div>
      <h2> {RR.s(label)} </h2>
      <DateInput date onChange=setDate />
      <Order items date />
    </div>;
  };
};

Then refactor Demo.make to use the new component:

re
[@react.component]
let make = () => {
  <div>
    <h1> {RR.s("Order Confirmation")} </h1>
    {datasets
     |> List.map(((label, items)) => <DateAndOrder key=label label items />)
     |> RR.list}
  </div>;
};
[@react.component]
let make = () => {
  <div>
    <h1> {RR.s("Order Confirmation")} </h1>
    {datasets
     |> List.map(((label, items)) => <DateAndOrder key=label label items />)
     |> RR.list}
  </div>;
};

5. Make the message for Discount.getSandwichHalfOff’s `MissingSandwichTypes error more friendly by listing the sandwiches you still need to buy to fulfill the conditions of the promotion. As a start, change the “Not all sandwiches, return Error” test in DiscountTests.SandwichHalfOff:

re
test("Not all sandwiches, return Error", () =>
  expect
  |> deepEqual(
       Discount.getSandwichHalfOff(
         ~date=june3,
         [
           Sandwich(Unicorn),
           Hotdog,
           Sandwich(Portabello),
           Sandwich(Ham),
         ],
       ),
       Error(`MissingSandwichTypes(["turducken"])),
     )
);
test("Not all sandwiches, return Error", () =>
  expect
  |> deepEqual(
       Discount.getSandwichHalfOff(
         ~date=june3,
         [
           Sandwich(Unicorn),
           Hotdog,
           Sandwich(Portabello),
           Sandwich(Ham),
         ],
       ),
       Error(`MissingSandwichTypes(["turducken"])),
     )
);

Note that the `MissingSandwichTypes variant tag now has an argument which is a list of strings.

Hint 1

Inside Discount.getSandwichHalfOff, use List.filter to filter out sandwich types that don’t appear in items.

Hint 2

In Promo.make, use Stdlib.Array.of_list and Js.Array.join to create a comma-delimited string.

Solution

Change the switch expression inside Discount.getSandwichHalfOff so that when there are missing sandwich types, they are collected in a list and returned as the argument of `MissingSandwichTypes error tag:

re
switch (tracker) {
| {portabello: true, ham: true, unicorn: true, turducken: true} =>
  let total =
    items
    |> ListLabels.fold_left(~init=0.0, ~f=(total, item) =>
         total +. Item.toPrice(item, ~date)
       );
  Ok(total /. 2.0);
| tracker =>
  let missing =
    [
      tracker.portabello ? "" : "portabello",
      tracker.ham ? "" : "ham",
      tracker.unicorn ? "" : "unicorn",
      tracker.turducken ? "" : "turducken",
    ]
    |> List.filter((!=)(""));
  Error(`MissingSandwichTypes(missing));
};
switch (tracker) {
| {portabello: true, ham: true, unicorn: true, turducken: true} =>
  let total =
    items
    |> ListLabels.fold_left(~init=0.0, ~f=(total, item) =>
         total +. Item.toPrice(item, ~date)
       );
  Ok(total /. 2.0);
| tracker =>
  let missing =
    [
      tracker.portabello ? "" : "portabello",
      tracker.ham ? "" : "ham",
      tracker.unicorn ? "" : "unicorn",
      tracker.turducken ? "" : "turducken",
    ]
    |> List.filter((!=)(""));
  Error(`MissingSandwichTypes(missing));
};

Note that instead of using partial application in

reason
|> List.filter((!=)(""));
|> List.filter((!=)(""));

We could have instead written

reason
|> List.filter(s => s != "")
|> List.filter(s => s != "")

which is a little more verbose and arguably easier to understand.

Then change the render logic inside Promo.make’s `MissingSandwichTypes branch to convert the list of missing sandwich types to a comma-delimited string:

re
| `DiscountError(code) =>
  let buyWhat =
    switch (code) {
    | `NeedOneBurger => "at least 1 more burger"
    | `NeedTwoBurgers => "at least 2 burgers"
    | `NeedMegaBurger => "a burger with every topping"
    | `MissingSandwichTypes(missing) =>
      (missing |> Stdlib.Array.of_list |> Js.Array.join(~sep=", "))
      ++ " sandwiches"
    };
  <div className=Style.discountError>
    {RR.s({j|Buy $buyWhat to enjoy this promotion|j})}
  </div>;
| `DiscountError(code) =>
  let buyWhat =
    switch (code) {
    | `NeedOneBurger => "at least 1 more burger"
    | `NeedTwoBurgers => "at least 2 burgers"
    | `NeedMegaBurger => "a burger with every topping"
    | `MissingSandwichTypes(missing) =>
      (missing |> Stdlib.Array.of_list |> Js.Array.join(~sep=", "))
      ++ " sandwiches"
    };
  <div className=Style.discountError>
    {RR.s({j|Buy $buyWhat to enjoy this promotion|j})}
  </div>;

Recall that we have to use Stdlib.Array.of_list instead of Array.of_list because our custom Array module takes precedence.


View source code and demo for this chapter.



  1. In OCaml terminology, variant tags start with ` and correspond to polymorphic variant types, while variant constructors correspond to normal variant types. ↩︎

  2. It might be a little confusing that Js.Date.get* functions all return float instead of int. The reason is that these functions must return NaN if the input Date is invalid, and in OCaml, only float is capable of representing NaN. ↩︎

  3. If you happen to need more than 7 dependencies, you can define your own binding function based on the current binding functions. We’ll cover bindings in more detail later. ↩︎

  4. Recall that variant constructors with arguments also get turned into objects in the JS runtime. ↩︎

  5. Some other prop names which cannot be used in their original form are: as_, open_, begin_, end_, in_, and to_. ↩︎