Introduction

This article is part of a growing series on Rescript, a language I am very excited about. I am by no means a master of the language, but I hope these articles are somewhat helpful. I fear I may accidentally be writing my next book here!

This article focuses on managing state in Rescript, specifically redux-style reducers. There is a lot less boilerplate with reducers in Rescript than in JS because Rescript is immutable and functional by default.

If you are doing a complex app, have a look at Reductive. I haven’t tried it, but in my experience, many, if not most, apps don’t actually need global state. We will use React hooks for our state instead.

Realistically, you’d probably put this in local storage or in the cloud, but that wouldn’t teach us anything about Rescript. I have a longer full-scale web app tutorial in mind that may cover such details in future.

Patreon

This series takes a lot of time to write and maintain. I’ve started it while I have some free time between jobs, but I’ll probably need some extra motivation to keep working on it once I’m busy with work again.

I’m not one for begging for money on the Internet, but if you’re interested in providing some of that motivation, I’ve set up a Patreon account. I thank you for your support.

As a bonus for patrons, articles will be published there at least two weeks before they make it to my public blog.

Table of Contents

Designing our state

We’ll continue with the Recipes example we’ve seen in previous articles.

The in-memory state for our app is fairly simple: A recipe has:

  • a title
  • ingredients list
  • instructions
  • tags

We’ll need a map-like object mapping titles to recipes, and a second map-like object mapping tags to a list of titles (like an index).

Note: I realized after the fact that I should also have given recipes integer ids I’ll address this in a later article about using rescript with express.

We’ll manage all this state from a single useReducer.

Let’s begin

This article extends the code written in the previous one. If you haven’t been following along you can clone this repo and git checkout the routing-nav branch. That’s the state of the project as of the end of the previous article. The commits for this article are collected in the actions-reducers branch. You may want to open them to follow along with how the generated javascript files compare to the input rescript sources.

Create a new file named Store.res. Rescript modules are named after the capitalized name of their file so, for consistency, it’s best to name the file starting with a capital letter. Even if the file was named store.res you would still access it as Store from other modules.

Add a type for an individual recipe:

 type recipe = {
   title: string,
   ingredients: string,
   instructions: string,
   tags: array<string>,
 }

This should be fairly familiar if you’re used to typescript or flow. There is one notable difference from javascript: In Rescript, arrays can hold only one type. So, the array<string> syntax says this particular array holds strings. You can’t put a number or anything else into it. Just strings.

A more typesafe array

Oddly, the Rescript standard library array is not fully type-safe. It can throw an exception if you access an invalid element. This is the same as Javascript code, but exceptions are a runtime error that in a lot of cases can be caught at compile time instead.

For extra safety, let’s use the elt](https://rescript-lang.org/docs/manual/latest/api/belt) standard library. “Belt” because rescript used to be called bucklescript and Belts have buckles.

It is weird to me that there are two “standard” libraries. As far as I can tell, Belt is generally safer (type-checker wise) It’s weird, though because using it changes the behaviour of default syntax.

For example, consider:

let value = [5, 3, 8][10]

This is accessing index 10 of a 3 element array, which will throw an exception at runtime. This is unlike Javascript, which would just return undefined.

But if you put open Belt at the top of the file containing that statement, value will be an Option<int> instead! You may recall options from the switch statement on rootQuery in the first article. The existence of an option forces you to check for a None value at compile time, thus reducing runtime errors.

My guess is the Rescript devs intend to eventually make Belt behaviour the default, but I haven’t confirmed that with them.

I think putting open Belt at the top of most files is a good idea. Rescript docs say this is the only use of open at the top of a file they recommend. It’s generally fine to use open in local scopes, but you don’t normally want to pollute an entire module’s namespace with imported symbols. They’re hard to find.

Enough discussion! Just go ahead and put open Belt at the top of the Store.res file.

Overall state

Now we can add the type for our store’s overall state. It will contain:

  • An index of string names to recipes
  • An index of tag names to array of strings

The syntax is a little surprising but straightforward:

type state = {
  recipes: Map.String.t<recipe>,
  tags: Map.String.t<array<string>>,
}

There is a generic Map type, but it’s awkward to use. Map.String is used in the case when the keys of the map are all strings. It’s a bit more efficient and has a more ergonomic API.

The .t bit is specifying the type of the map. t is a common convention in the Rescript ecosystem. Rescript is a functional programming language and doesn’t have objects. However, if you create a module with a t type, and all the functions in that module accept a t as its first argument, it feels very much like an object in other languages.

The values in a Map.String.t are generic. They must all be the same type for any one map, but Rescript doesn’t care what type that is. We have to add a specilization to indicate that the map values are recipes in the one case and arrays of strings in the other.

Reminder: those arrays are Belt.Arrays, not standard arrays because we used open Belt.

While we’re at it, let’s also define the initial state for our reducer:

let initialState: state = {
  recipes: Map.String.empty,
  tags: Map.String.empty,
}

I want to highlight that you are assigning both values to “The Map.String.empty” map. There is only one empty Map.String. Both recipes and tags are pointing at the exact same Map.

This works because Map.String is immutable. When you set a new value in the map it returns a brand new Map.String. So the empty one can contain the exact same thing every time.

Actions and reducer

If you aren’t familiar with redux or the useReducer hook, a reducer takes two arguments, state and action, and returns a brand new state.

In javascript, the action is usually an object with a string type and an arbitrary payload Rescript can do better with stricter typing and no boilerplate because the type is encoded in the language as a variant.

For our reducer, we’ll have two actions:

  • AddRecipe accepts title, ingredients and instructions and returns a new state with the recipe in the array
  • AddTag accepts title and tag and returns a new state with the tag in the recipe’s tags array and the recipe in the tags map

Here they are encapsulated in a single variant type:

type action =
  | AddRecipe({title: string, ingredients: string, instructions: string})
  | AddTag({recipeTitle: string, tag: string})

We’ve seen variant before in the Option and the routers, but it’s probably time to go a bit deeper. The variant type is a sort of union of multiple types, and each of those multiple types can have a completely different payload. If you’re familiar with enums in other languages, it’s kind of like that, except each enum value can have some strongly-typed state attached to it.

For fun, let’s compare the above to the verbose overhead of equivalent Redux code:

export type ADD_RECIPE = "recipes/add";
export type ADD_TAG = "tags/add";

export const addRecipe = (title, ingredients, instructions) => ({
  type: ADD_RECIPE,
  payload: {
    title,
    ingredients,
    instructions,
  },
});

export const addTag = (recipeTitle, tag) => ({
  type: ADD_TAG,
  payload: {
    recipeTitle,
    tag,
  },
});

Personally I find the rescript version a wee bit more readable… and the JS code isn’t even typesafe! It would be both more verbose and more typesafe if you used Typescript, but it would be even harder to read.

Building the AddRecipe reducer

The reducer is necessarily more complicated than the action because that’s where all the business logic goes. I find it’s still far less boilerplate than standard redux, and it lets us leverage some functional concepts to great effect.

Let’s start with the reducer function signature:

  let reducer = (state: state, action: action) => {
    switch action {
    | _ => initialState
    }
  }

We switch on the action type in a pattern match. In this case, no matter what the action is, it returns the initial state. By the way, as I predicted, I keep forgetting that damn | symbol to indicate variant! Remember that a function “returns” the result of the last expression in the function. So this one returns the result of the switch statement, which is always the initial state.

It’s not a very useful reducer, but it is a reducer. Let’s process the AddRecipe action. It needs to update the recipes on the incoming state without changing the tags.

let reducer = (state: state, action: action) => {
  switch action {
  | AddRecipe({title, ingredients, instructions}) => {
      recipes: Map.String.set(
        state.recipes,
        title,
        {title: title, ingredients: ingredients, instructions: instructions, tags: []},
      ),
      tags: state.tags,
    }
  | _ => initialState
  }
}

We start by destructuring the AddRecipe action in the switch pattern. Then we pass it into the set function in the Map.String module. Map.String.set accepts a Map.String.t, a key, and a value. It returns a new Map.String.t. This is important: everything is immutable, so it’s a brand new map. Whatever was previously in the state.recipes array does not change.

As I mentioned earlier, the first argument to set is the Map.String.t that is being modified. This gives us a sort of object-oriented feel. There is no such thing as a method in Rescript, but the truth is, under the hood, all object oriented languages are passing some sort of this implicitly (or explicitly, for Python).

If you spend any time reading the documentation for Map.String, you’ll see that it’s rather sparse. I found it useful to read the (slightly) more complete documentation for Map It takes some practice to extrapolate from the generic docs to the specific one, though!

The new returned state contains the same tags as state.tags, but, using Map.String.set, it has created a new recipes map with one more entry.

For any other actions (AddTag is the only one right now) it currently just returns the initial state.

No methods?

Before we add the AddTag action I want to think more about the idea of methods. While I understand the concept of Map.String.set(state.recipes, ...) compared to the JS equivalent of state.recipes.set(), I am more used to that second syntax. As are all programmers used to any of the most popular OOP or structured languages.

Luckily we can sort of have both worlds. Rescript has a nifty syntax called the pipe.

To quote the docs, it “flips your code inside out”. Essentially the item on the left side of a -> becomes the first argument to the function on the right side.

In code, it just allows us to move state.recipes outside the Map.String.set function call as follows:

recipes: state.recipes->Map.String.set(
  title,
  {title: title, ingredients: ingredients, instructions: instructions, tags: []},
),

Note that the whole function name Map.String.set is still necessary, so it’s not quite as simple as state.recipes.set. You could remove this by having an open Map.String somewhere in scope, but it’s easier to understand where Map.String.set is defined than set. set is a common word that could have several meanings in one namespace, so it’s better to be explicit.

Add Tag

This case is quite a bit more complicated. We need to do two things:

  • check if the recipe exists and only add the tag if it does
  • update both the recipe and the tags array

Warning: My first attempt at this is going to look pretty nasty. I’ll improve it shortly!

Start with the boilerplate to check if the recipe exists by replacing the | _ => initialState in the swith with:


| AddTag({recipeTitle, tag}) => {
  let recipeOption = state.recipes->Map.String.get(recipeTitle)

  switch recipeOption {
  | Some(recipe) => state
  | None => state
  }
}

As the inverse of Map.String.set, Map.String.get returns an option variant, depending whether the requested key is in the Map. We use the pipe operator again. For now, it return the state unchanged regardless of whether it is found.

It compiles, so I’ll assume it works. You have hopefully noticed my complete lack of unit tests. I haven’t researched how to test in rescript yet! But Rescript has strong type guarantees, which means so “assume it works when it compiles” is a slightly safer assumption than in Javascript or even Typescript.

Let’s add the last state transition, when the recipe exists. We need to construct the new recipes object, with the updated tags. Replace the state for the `Some(recipe) arm with:

{
  let recipeTags = recipe.tags->Array.concat([tag])
  let recipes = state.recipes->Map.String.set(recipe.title, {...recipe, tags: recipeTags})
}

This is performing two immutable operations. First it creates a new array of tags for the recipe using Array.concat. I expected the spread operator (...) to work for generating a new array but the compiler politely says it isn’t supported yet. Second, it updates the recipe to one with the new tags using the same Map.string.set syntax as the earlier AddRecipe variant.

The second piece, to update the array of tagged recipes looks like this:

let tags = state.tags->Map.String.update(tag, taggedRecipesOption =>
  switch taggedRecipesOption {
  | None => Some([recipe.title])
  | Some(taggedRecipes) => Some(taggedRecipes->Array.concat([recipe.title]))
  }
)

This uses the slightly convoluted Map.String.update function, which accepts three things:

  • the map itself, which is passed in as state.tags by the pipe operator
  • the key that is being updated within that map, tag
  • A callback function that accepts the current value for that key and returns the new one.

The callback accepts a single value, an Option with the current value for the requested key. It will be either None or Some(recipes) depending whether the value was already in the array.

So we have to switch on that and act accordingly.

The return value of the callback is also an option. If we returned None, it would remove the element from the map. In our case we just wrap it in Some.

Finally, we return the newly constructed state (no return statement needed!):

{
  recipes: recipes,
  tags: tags,
}

If that explanation was unclear, here’s the code for the entire reducer. It’s probably not very clear either, yet. ;-)

  let reducer = (state: state, action: action) => {
    switch action {
    | AddRecipe({title, ingredients, instructions}) => {
        recipes: state.recipes->Map.String.set(
          title,
          {title: title, ingredients: ingredients, instructions: instructions, tags: []},
        ),
        tags: state.tags,
      }
    | AddTag({recipeTitle, tag}) => {
        let recipeOption = state.recipes->Map.String.get(recipeTitle)

        switch recipeOption {
        | Some(recipe) => {
            let recipeTags = recipe.tags->Array.concat([tag])
            let recipes = state.recipes->Map.String.set(recipe.title, {...recipe, tags: recipeTags})

            let tags = state.tags->Map.String.update(tag, taggedRecipesOption =>
              switch taggedRecipesOption {
              | None => Some([recipe.title])
              | Some(taggedRecipes) => Some(taggedRecipes->Array.concat([recipe.title]))
              }
            )

            {
              recipes: recipes,
              tags: tags,
            }
          }
        | None => state
        }
      }
    }
  }

I discovered after writing this that it can be cleaned up with copious use of the pipe operator and Option.flatmap and Option.map in the Belt library. I’ll cover this in a later article.

Confirm it’s really a reducer

Let’s see if that reducer and action setup even works with useReducer! It’s a small amount of state, so I’m going to load it in App.res and pass it around as props. (A React context might also be suitable).

let (state, dispatch) = React.useReducer(Store.reducer, Store.initialState)
  • This compiles, so once again I’m going to assume it works… until proven otherwise.

Conclusion

The thing I love about this tutorial is that I am very confindent my reducer works, even though I haven’t unit tested it or even tried to access it in the UI. Obviously, you should do those things; Rescript doesn’t magically make your code bug free. However, t does give you a lot of confidence that there aren’t as many “silly mistakes”. In JS, you catch “silly mistakes” using unit tests. The rescript compiler is so fast that you get the feedback instantly. This improves developer velocity and feels much more comfortable.

In the next article, we’ll further confirm that the reducer works by connecting it to a component.