Actions and Reducers in Rescript
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.
Other articles in series
With over a dozen articles and counting, I’ve created a table of contents listing all the articles in this series in reading order.
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 recipe
s in the one case and
arrays of strings in the other.
Reminder: those arrays are Belt.Array
s, 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 arrayAddTag
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.