Introduction

This is part of an ongoing series about the Rescript programming language. I’ve been building a toy app and taking notes as I go along. I’m already pretty comfortable in the language, and I haven’t loved coding in anything this much since I first discovered Python.

This article is a bit of a breather in that it doesn’t have a ton of new concepts. I’m just building out the app in preparation for new content in the future. I’ll be adding components for rendering all tags and to view an individual recipe. The next article will go into detail of hooking up some forms to the actions we wrote in the previous one.

Patreon

This series takes a lot of time to write and maintain. I 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 are being published there at least two weeks before they make it to my public blog.

Table of Contents

Let’s begin

If you haven’t been following along with these articles, you can check out the action-reducers branch of the accompanying github repository. That is the state of the repo as of the end of the previous article. This article’s commits will go in the components branch.

As a refresher, we are building a “Recipe Book” app. So far, we have the basic Rescript boilerplate in place along with routing and navigation and some simple redux-style state. In this article, we’ll add a couple of presentational components, and in the next one, we’ll hook up some form to fire actions for those reducers.

The all tags boilerplate

You would think it would make sense to start with adding a tag so we can get data into the reducer in order to render it. But AddTag will be the most complicated component in the system. In my daily work, I usually tackle the most complicated things first because it helps make sure that my convoluted evil plans will actually work. But when I’m learning or teaching, I tackle the simple things. That way, I can discover something that will help me understand the harder part later.

So we’ll start with the much simpler AllTags route. Create a new file named AllTags.res. Remember the note in a previous article about starting a module file with a capital letter. I’m putting all the components directly in the src/ directory since it’s not a very big project, but you can create a folder hierarchy if you prefer.

Next, whip out a quick dummy component with no props:

@react.component
let make = () => {

<div>{React.string("All tags")}</div>
}

Then update the All tags variant arm in App.res to load it:

| list{"tags"} => <AllTags />

This isn’t a very useful component. We need to add some properties to the component so we can receive a list of tags. Add open Belt at the top of AllTags.res so we can use the Map.String.t type and arrays can’t throw exceptions.

Change the component’s function definition to

let make = (~tags: Map.String.t<array<string>>) => {

This allows us to accept a tag. Then change the accompanying line in App.res to | list{"tags"} => <AllTags tags={state.tags} />.

At this point, I threw a Js.Console.log(tags) call into the make function. I was surprised to discover that it was undefined - Which is apparently what Map.String.empty compiles to! - See Store.bs.js if you don’t believe me!

Sample data

Before we can render tags, we need some tags to actually render.

There are a couple ways we could get this:

  • Easiest: Put it in initialState directly
  • Hardest: Implement the add recipe and add tag forms first
  • Most educational: dispatch the necessary actions to watch the state change We can do this with a useEffect hook in our App.res. I like educational, so let’s choose this route!

Here’s the hook (put it just below the React.useReducer call in App.res):

React.useEffect1(() => {
  dispatch(
    Store.AddRecipe({
      title: "Bread",
      ingredients: "flour, salt, water, yeast",
      instructions: "Mix, let rise overnight, bake at 400",
    }),
  )
dispatch(Store.AddTag({recipeTitle: "Bread", tag: "carbs"}))
None
}, [dispatch])

Even if you’re used to the useEffect hook in Javascript, this may need some explaining. (And, if you aren’t used to useEffect, read here). Rescript-react makes a couple subtle changes to the API that you need to know about.

First, note how the method is named useEffect1. The 1 is because the deps array [dispatch] contains one argument. If you had two arguments it would be useEffect2 and you’d use a tuple (arg1, arg2).

Rescript doesn’t have the concept of an array of arbitrary types and arbitrary length So they use tuples instead, which allow arbitrary types, but not arbitrary length. So the method name has to encode that arbitrary length. If you aren’t confused enough yet (I am), useEffect1 is special because there is no such thing as a tuple with a single value, so for this special case, we pass an array instead.

This API is not ergonomic and I don’t like it. But I understand why it’s necessary to get other benefits of Rescript. Provable type safety is great, but the thing I really love is the speed. If they had to slow the compiler down to make this API more appealing, I’d prefer fast and ugly!

Another difference you should notice is that the method explicitly returns None. In Javascript, useEffect can return either undefined or a function that can be used to clean up the effect.

In Javascript, undefined is returned implicitly (if you don’t return anything from a function) so you never see return undefined in your effects. But Rescript doesn’t have the concept of undefined, so your rescript UseEffect has to return either None or Some(() => ...).

In our case, there’s no cleanup required, so we return None.

Inside the effect body, we are dispatching two events:

  • one to add a recipe
  • one to add a tag to that recipe

The dispatch is just a function call, and contains a variant for the action that we defined in the previous article.

my Js.Console.log(tags) statement is no longer ‘undefined’

You may want to add some more tags and recipes inside the hook during this testing phase.

(That was a legitimate reipe, btw;I make it all the time!)

Fleshing out the All Tags view

Now we can render the list of tags and recipes. This will look a little weird, but it’s fairly idiomatic Rescript. We’ll be really exercising the -> pipe operator.

  • Replace the AllTags.res
open Belt

@react.component
let make = (~tags: Map.String.t<array<string>>) => {
  let tagComponents =
    tags
    ->Map.String.toArray
    ->Array.map(((tag, recipes)) =>
      <div key={tag}> <h2> {React.string(tag)} </h2> <RecipeList recipes /> </div>
    )
    ->React.array
  <div> {tagComponents} </div>
}

(Note: I know RecipeList isn’t defined, yet. We’ll get to it in a moment.)

This shows how the pipe operator should really be used. The output of one -> becomes the input of the next one. I’m finding I write a lot of code that has four or five levels of pipes. It’s sort of like the a.b.c.d syntax in Javascript.

One thing that might trip you up is that when piping to a function with only one argument, you leave off the surrounding ().

It reads like a series of transforms:

  • Take the tags map and transform it into an array
  • transform that array into an array of tags
  • transform that array into a React.array

The Array.map function is similar to a map in Javascript; it applies a function to each of the elemnts in the array and returns an array of whatever that function returns.

The React.array type is kind of like the React.string we saw in the first article. It allows you to guarantee that the thing you are passing in is an array of React components.

A little comment about formatting: I wrote it with the <div key={tag}>...</div> all on separate lines. I think it was more readable like that, but the rescript formatter insisted on merging it.

However, one of my core tenets is “never argue with the default formatter”. Arguing about formatting (whether with other coders or with the formatting software) is a colossal waste of time: more time than is wasted by trying to read the output.

One more neat feature to note is that you can pass a recipes prop without specifying the value. In traditional jsx, this would be <RecipeList recipes={recipes} /> This is known as punning I don’t know why, but it’s a funny word.

not currently compiling.

RecipeList

We’re still missing the RecipeList component. Let’s add that so the compiler stops complaining. You might want to try this yourself before looking at my code.

Cretae a new RecipeList.res file as follows:

open Belt

@react.component
let make = (~recipes: array<string>) => {
  <div>
    {recipes
    ->Array.map(recipe =>
      <div key={recipe} onClick={_ => RescriptReactRouter.push(`/recipes/${recipe}`)}>
        {React.string(recipe)}
      </div>
    )
    ->React.array}
  </div>
}

This just takes the array of recipes and passes it into map to create an array of components. I’ve added a RescriptReactRouter.push call like we used in the NavBar so the recipes can link to the relevant view.

Now visit (http://localhost:3000/tags)[http://localhost:3000/tags] to see the tags you defined in the useEffect earlier. It should look like this:

all tags view

And clicking the recipe titles should take you to an individual view recipe component.

One more react component (View Recipe)

We’ll do one more top-level component. Create a new file ViewRecipe.res with some dummy content:

@react.component
let make = (~state: Store.state, ~title: string) => {
  <div> {React.string("View Recipe " ++ title)} </div>
}

Update the route matching in App.res:

| list{"recipes", title} => <div> {<ViewRecipe state title />} </div>

Note the elegant punning for passing state and title. This is great; I do so dislike unnecessary code repetition.

The page is loading fine in my browser, so we can quickly hop to customizing it. Have you noticed yet how lightning fast Rescript is to compile? I’m not sure if I’ve mentioned before how much I love it. ;-) It gets everything recompiled before the react hot loader notices anything has changed.

First, let’s look up the recipe in the state (this won’t compile yet):

open Belt

@react.component
let make = (~state: Store.state, ~title: string) => {
  let recipe = state.recipes->Map.String.get(title)

  <div> {React.string("View Recipe " ++ recipe.title)} </div>
}

“We’ve found a bug for you!” the compiler announces, and indeed it has.

If I had been writing raw JS I may have forgotten that the recipe value might be undefined.

Rescript won’t let me do that because the recipe returned by get is actually an option.

Let’s fix it to handle the None case correctly:

open Belt

@react.component
let make = (~state: Store.state, ~title: string) => {
  switch state.recipes->Map.String.get(title) {
  | None => <div> {React.string(title ++ " is not in our database")} </div>
  | Some(recipe) => <div> {React.string("View Recipe " ++ recipe.title)} </div>
  }
}

Rendering an individual recipe

Displaying the actual recipe involves rendering four pieces:

  • title
  • ingredients
  • instructions
  • tags

For readability, let’s put this logic into its own function, given a recipe as a prop:

let displayRecipe = (recipe: Store.recipe) => {
  <div>
    <h2> {React.string(recipe.title)} </h2>
    <div>
      <h3> {React.string("Ingredients")} </h3> <div> {React.string(recipe.ingredients)} </div>
    </div>
    <div>
      <h3> {React.string("Instructions")} </h3> <div> {React.string(recipe.instructions)} </div>
    </div>
    <div>
      <h3> {React.string("Tags")} </h3>
      <div> {recipe.tags->Array.map(tag => <div> {React.string(tag)} </div>)->React.array} </div>
    </div>
  </div>
}

Note that this function is not a @react.component; it just returns JSX. Either would work, but for a simple component like this, it didn’t seem necessary. I’d do the same in standard react in Javascript.

Calling this function inside the switch statement brings no surprises:

@react.component
let make = (~state: Store.state, ~title: string) => {
  switch state.recipes->Map.String.get(title) {
  | None => <div> {React.string(title ++ " is not in our database")} </div>
  | Some(recipe) => displayRecipe(recipe)
  }
}

An exercise for the reader

I’m getting bored with writing presentational components, so I’m going to pass the torch to you so I can start writing about the Add Recipe form. Here are a couple things you might consider adding to the current code:

  • Consider adding a new route for /tags/<tagname> to view a list of tags
    • You can reuse the RecipeList component
    • You can add links to individual tags from inside the ViewRecipe component
  • Maybe add a random recipe selector to the home page
  • I should have added a recipe id to the recipe state
    • You could try adding it
    • You’d need to add a nextId integer type to the state
    • change all the urls to go by id instead of title

Conclusion

This article created a few new components. We saw how to add properties to components and some new uses of the pipe operator. The current state of the project can be found on github in the components branch.

At this point, I can definitively say that I love this language. I have to write Typescript in some other projects and it feels so annoying now. Six months ago when I started writing Typescript, I felt like I was in heaven, compared to standard Javascript! That’s how much better Rescript is.

I really hope the project takes off, and that this articles help make that happen.

If you like this content, and want to see more of it, I humbly ask you to check out my patreon. I will be posting articles two weeks early for subscribers to give me feedback.

I’m not looking for this to be a major money maker. It’s been my pleasure to write these articles lately, as I’ve been between jobs. However, once I’m back to work, it’s going to require some extra motivation to keep me at it. If you want to see the work, please do subscribe to show your appreciation.

More excitingly, if I get enough subscribers, I promise to turn this content into a book! :-)