Introduction

This is part of an ongoing series about the Rescript programming language. I’m taking notes as I build a toy “Recipe Book” app to explore the Rescript ecosystem.

In my most recent articles, I started investigating the world of offline-enabled apps using RxDB. This project does not have Rescript bindings, so my first step was to model the library in Rescript.

This article merges the components defined in my earlier series on React components in Rescript with the articles on RxDB. By the end of this next sequence, we’ll have a completely functional offline recipe book application.

Patreon

This series takes a lot of time to write and maintain. I’m trying to maintain a weekly cadence, but since my new job began, I’m finding it hard to stay motivated.

If you’re interested in providing some of that motivation, I’ve set up a Patreon account. I thank you for your support. I am not expecting this to pay for my time investment, but it makes a huge difference to know if people appreciate it enough to make a contribution. This series is not edited to the quality level of my published books, but at nearing 50,000 words, the rough content is certainly book-worthy.

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

Other ways to show support include sharing the articles on social media, commenting, or throwing a quick thank you on the Rescript forum thread.

With over a dozen articles and counting, I’ve created a table of contents listing all the articles in this series in reading order.

Table of Contents

Let’s begin

If you haven’t been following along with these articles, you can git checkout the subscriptions branch of the accompanying github repository to start at the same place I am.

If you want to follow along, I make commits to the components branch that roughly map to the sections in this article. You may find it instructive to see the diff of the JS that the rescript compiler outputs with each change in your rescript code.

I will also be pulling in several react components from the rescript-react-intro repository that I used for several previous articles, specifically from the emotion-styling branch.

Note: I normally try to make my articles somewhat self-contained, but this one is really just a huge list of references to code written in previous articles. It may be rather confusing if you aren’t familiar with the earlier code.

Bring in some React components

A lot of this article is just going to be gluing together bits and pieces of previous articles. The rescript-react-intro articles have all our components, but they are connected to a Store managed by a useReducer hook. We will be dropping that Store altogether and attaching the components directly to the DB from the later RxDB articles.

(To be clear, the repository we are modifying is rescript-offline and the repo we are copying components from is rescript-react-intro.)

Start by copy-pasting all of these components directly from the rescript-react-intro repo (or if that bores you, you can start with the appropriate commit in the components branch of the rescript-offline repo).

  • AddRecipeForm.res
  • AddTag.res
  • AllTags.res
  • CardStyles.res
  • NavBar.res
  • RecipeList.res
  • ViewRecipe.res

They will fail to compile for a variety of reasons, but don’t worry about that, yet. There are two other files in the react repo that we need to port over, but they are going to conflict with the files we already have, so we’ll have to merge them judiciously: App.res and Index.res.

Index.res is pretty simple, so let’s start with that: Copy only the block with the global styles from Index.res in the rescript-react-intro repo. Make sure not to lose the bit of Index.res that registers service workers.

Port App.res

We need to keep all our Db logic in App.res, but we want to bring the Navigation bar in from rescript-react-intro.

First, add a useUrl hook under the various hooks that already exist in App.res’s make function:

let (db, setDb) = React.useState(() => None)
let (recipes, setRecipes) = React.useState(_ => Belt.Map.String.empty)
let (tags, setTags) = React.useState(_ => Belt.Map.String.empty)
let url = RescriptReactRouter.useUrl()

Second, replace the entire create-react-app boilerplate div with the component and navbar from the rescript-react-intro repo:

  let component = switch url.path {
  | list{"recipes", "add"} => <AddRecipeForm dispatch />
  | list{"recipes", title} => <div> {<ViewRecipe state title dispatch />} </div>
  | list{"tags"} => <AllTags tags={state.tags} />
  | list{} => <div> {React.string("Home page")} </div>
  | _ => <div> {React.string("Route not found")} </div>
  }

  <div> <NavBar /> {component} </div>
}

Note that this won’t compile, yet, because we don’t have dispatch or state anymore.

For the most part, the rest of our work is just fixing compiler errors. Once it compiles, we can be pretty confident that it works.

db may be None

That last change is insufficient, though. We’ll soon be changing all those components to access the db that is initialized in the effect rather than the old reducer-style store. However, that db variable may be None until the effect is finished executing.

To solve this, change the db useHook to name the variable dbOption, for clarity:

let (dbOption, setDb) = React.useState(() => None)

Then add a switch around the component we just added:

switch dbOption {
| None => <div> {React.string("Loading your database...")} </div>
| Some(db) => {
    let component = switch url.path {
    | list{"recipes", "add"} => <AddRecipeForm dispatch />
    | list{"recipes", title} => <div> {<ViewRecipe state title dispatch />} </div>
    | list{"tags"} => <AllTags tags={state.tags} />
    | list{} => <div> {React.string("Home page")} </div>
    | _ => <div> {React.string("Route not found")} </div>
    }

    <div> <NavBar /> {component} </div>
  }
}

Dependencies

The rescript-react-intro repo depends on bs-css-emotion, so we should add a dependency on that, as well as @rescript/react if it isn’t already there. We’ll also be generating string IDs using uuid, so install that dependency as well.

npm install --save bs-css-emotion @rescript/react uuid will install the dependencies.

Then update the bs-dependencies in bsconfig.json as follows:

  "bs-dependencies": [
    "@rescript/react",
    "bs-css",
    "bs-css-emotion",
    "@ryyppy/rescript-promise"
  ]

Upsert

We’ll need to add an upsert method to RxCollection in Db.res to make it easier to update tags and recipes. Upsert means, “If a record with that primary key exists, overwrite it, if not, insert a new record”.

This can pretty much mirror the existing insert binding:

  @send external upsert: (t<'docType>, 'doctype) => Promise.t<RxDocument.t<'docType>> = "upsert"

Port AddRecipeForm

There are two things we need to do to port AddRecipeForm to the offline database:

  • Pass in a callback instead of relying on the existence of dispatch
  • Assign a string id to recipes

The second one is basically an API incompatibility between the original React app and the new one. I neglected to put an id field in the original app. It’s time to rectify that!

The ID field is a string (which RxDB demands). We could track an auto-incrementing id, but instead I decided to give each recipe a random UUID. To do that we need to add a binding to the uuid library (which we added as a new dependency above). Put this at the top of AddRecipeForm.res:

@module("uuid") external uuid: unit => string = "v4"

Now we’ll need to change the AddRecipeForm.make signature to accept a callback property instead of dispatch for the addRecipe action. The callback will accept the recipe to be changed and return a Promise.t.

let make = (~dispatch: Store.action => unit) => {

has to be changed to

let make = (~addRecipe: Model.recipe => Promise.t<unit>) => {

Then, of course, we need to change the dispatch call to something that creates an id and calls this callback. Since the callback returns a promise, we don’t want to push the new recipe’s URL onto RescriptReactRouter until after that promise resolves. Change the contents of the onClick handler to:

onClick={_ => {
  let id = uuid()
  addRecipe({
    id: id,
    title: title,
    ingredients: ingredients,
    instructions: instructions,
    tags: [],
  })
  ->Promise.map(_ => RescriptReactRouter.push(`/recipes/${id}`))
  ->ignore
}}

That ->ignore at the end says, “We don’t care about the return value of this pipeline. It’s similar to saying let _ = .... You’re just telling Rescript that it’s ok you aren’t handling this result.

Finally, we need to actually pass that addRecipe property into AddRecipeForm from App.res. Replace the existing <AddRecipeForm /> arm in the router with:

| list{"recipes", "add"} =>
  <AddRecipeForm
    addRecipe={recipe => db.recipes->Db.RxCollection.insert(recipe)->Promise.map(_=>())}
  />

The Promise.map(_ => ()) at the end turns the Promise returned by insert (which returns an RxDocument) into a Promise that returns unit, which is what the AddRecipeForm callback expects.

Port RecipeList

The original RecipeList.res component accepts an array of recipe titles and renders them with links to that title. This component needs an extra layer now because we are using ids instead of titles. We need to render a link to the id, but the contents of the link should be the title.

I will pass the recipes map into the component along with the array of recipeIds so that the component can be responsible for looking up the correct title for a given id.

However, this introduces another wrinkle because it’s possible the given id is not in the recipes map. This “shouldn’t happen” and in Javascript, you’d probably just ignore it. Let’s go to the effort to be a little safer than that, since Rescript makes it easy! We can use the same Array.keep trick we used in the article on using Rescript with express.

Here’s the entire new RecipeList.res component:

open Belt

@react.component
let make = (~recipeIds: array<string>, ~recipes: Map.String.t<Model.recipe>) => {
  <div>
    {recipeIds
    ->Array.map(recipeId => {
      recipes
      ->Map.String.get(recipeId)
      ->Option.map(recipe =>
        <div key={recipeId} onClick={_ => RescriptReactRouter.push(`/recipes/${recipe.id}`)}>
          {React.string(recipe.title)}
        </div>
      )
    })
    ->Array.keep(Option.isSome)
    ->Array.map(Option.getUnsafe)
    ->React.array}
  </div>
}

Remember that getUnsafe should be avoided, but in this case it’s ok because the keep has guaranteed that there are no None options.

Port AllTags

AllTags uses a RecipeList, so we need to update it to accept the recipes map and pass it down to the RecipeList object:

open Belt

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

We also need to update the router arm in App.res that renders the AllTags component with the new properties:

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

Port AddTag

The change to AddTag.res itself is not as bad as the ViewRecipe that calls it, so let’s do that first:

First, change the function signature to include an addTag callback. The callback needs to accept two arguments (the tag and id) and return a Promise.t.

The existing signature is:

let make = (~recipeTitle: string, ~dispatch: Store.action => unit) => {

and it needs to be changed to:

let make = (~recipeId: Model.id, ~addTag: (Model.tag, Model.id) => Promise.t<unit>) => {

Note how I changed to the more concrete types (Model.id, Model.tag instead of string) while I was at it.

Then we need to replace the onClick handler with one that calls this new callback:

onClick={_ => {
  addTag(tag, recipeId)->ignore
}}

Next, we need to update ViewRecipe.res to pass that callback through to AddTag.Change the ViewRecipe.displayRecipe signature to:

let displayRecipe = (recipe: Model.recipe, addTag: (Model.tag, Model.id) => Promise.t<unit>) => {

Note that I changed the recipe type from Store.recipe to Model.recipe, since we don’t have a Store anymore.

And inside that displayRecipe function, the AddTag component needs to pass down the addTag function and use recipeId instead of recipeTitle:

<AddTag addTag recipeId={recipe.id} />

Finally, the ViewRecipes.make function has to be changed to:

  • accept recipes instead of state
  • accept the recipe id instead of title
  • accept the addTag callback instead of dispatch
  • forward the appropriate values to the displayRecipe function

Here it is in its entirety:

@react.component
let make = (
  ~recipes: Map.String.t<Model.recipe>,
  ~id: Model.id,
  ~addTag: (Model.tag, Model.id) => Promise.t<unit>,
) => {
  switch recipes->Map.String.get(id) {
  | None => <div> {React.string("Recipe with id " ++ id ++ " is not in your database")} </div>
  | Some(recipe) => displayRecipe(recipe, addTag)
  }
}

The addTag callback

Now let’s create the function to be used for that callback. It’s going to be, frankly, messy, messy, messy. That’s why I left it for the end.

It’s best to create a helper function for it in App.res so we don’t have to pollute the routing switch statement. The function will need access to the db and current recipes and tags, as well as the tag and id being added to:

let addTagCallback = (
  db: Db.t,
  recipes: Belt.Map.String.t<Model.recipe>,
  tags: Belt.Map.String.t<array<Model.id>>,
  tag: Model.tag,
  id: Model.id,
) => {
}

The first thing we need to do is check if the recipe with the given id even exists:

switch recipes->Belt.Map.String.get(id) {
| None => Promise.resolve()
| Some(recipe) => Promise.resolve()
}

If it does exist, we need to create a new array of recipes for the given tag. There are two ways that can go: If the tag already exists in the tags Map, we need to extend the existing array. If it doesn’t, we need to create a new array. This can be done with the following pipeline:

| Some(recipe) => {
    let tagRecord: Model.taggedRecipes = {
      tag: tag,
      recipes: tags
      ->Belt.Map.String.get(tag)
      ->Belt.Option.getWithDefault([])
      ->Belt.Array.concat([id]),
    }
    Promise.resolve()
  }

In words, we pipe the tags map into Map.String.get to extract the previous recipes array. This returns an Option that we pass into getWithDefault which either:

  • returns the contents of a Some() option
  • returns the default [] if it’s a None option

Either way, the resulting array is concatenated with one containing the new id, which returns a new array with all the recipes for that tag including the new one.

Then we wrap the whole pipeline in a taggedRecipes object. This is the object that will be upserted into the db.

We need to do a similar thing with the recipe.tags value, but the pipeline is a bit simpler:

let newRecipe = {...recipe, tags: recipe.tags->Belt.Array.concat([tag])}

Finally, we need to call upsert on both the db.recipes and the db.tags collections to store these new values. Since upsert returns a Promise.t, we can run these two changes in parallel using Promise.all:

Promise.all([
  db.recipes->Db.RxCollection.upsert(newRecipe)->Promise.map(_ => ()),
  db.tags->Db.RxCollection.upsert(tagRecord)->Promise.map(_ => ()),
])->Promise.map(_ => ())

That function is looking pretty nasty, but it compiles. Now we need to change the ViewRecipe route to call it:

| list{"recipes", id} =>
  <div>
    {<ViewRecipe
      recipes id addTag={(tag, id) => addTagCallback(db, recipes, tags, tag, id)}
    />}
  </div>

Minor cleanup

Clicking around in the app, it actually seems to be working correctly. However, I see an error in the console and there are some parts of my code that I don’t love.

The console error is easily fixed; I forgot to put a key on the list of tags that get mapped in ViewRecipe.displayRecipe. Change the div that renders the tags as follows:

<div>
  <h3> {React.string("Tags")} </h3>
  <div>
    {recipe.tags->Array.map(tag => <div key={tag}> {React.string(tag)} </div>)->React.array}
  </div>
  <AddTag addTag recipeId={recipe.id} />
</div>

The only change is the key={tag} bit.

Another thing I don’t like is how verbose App.res is. One of the problems is that there are so many Belt.Map and Belt.Array calls in there. This is easily fixed with an open Belt in the file, and then a search and replace to remove all Belt. references. I won’t show the changes here, but you can find them in the git repo if you want them. I was pleasantly surprised at how much tidier just that one change made my code after the Rescript formatter ran. There are fewer linebreaks and it’s just less cluttered.

For my code, the compiler output has a warning about an unused open Belt in Model.res. The truth is, I expected this file to be doing a lot more work than it ended up doing. I just deleted that line.

Exercise for the reader

One of the errors I get when I’m poking around the app happens when I try to add a duplicate tag. The error is thrown by RxDB because I have a uniqueItems: truein the schema. Because RxDB complains, the error is more or less benign (although I see that there is a uniqueItems: true missing on the tags.recipes array in the same schema).

One way to fix this is to replace the arrays in the model with sets. This needs to be done thoughtfully, though, because we still need our model to match the schema, and the schema expects arrays.

The best way to solve this is probably to have separate types, one for the Model and one for the Db. The app interacts with Model types and the Db interacts with Db types. Then I would probably add some interfaces and adapters in Model to convert between the two.

Ideally, the App might not know about the existence of Db at all, and interacts with a much more pleasant Model interface that you have to define.

Is it really offline?

Follow the steps at the beginning of Part 1 to see if this app works offline:

  • First run npm run build to create production artifacts
  • cd into the build directory
  • Run npx http-server
  • Visit localhost:8080
  • Disconnect the server with ctrl-c
  • Navigate around the site and see everything working offline!

Conclusion

This ends my mini-series on creating an offline application. This application is currently offline-only, as opposed to a progressive web app. The next step will be to connect it to a graphQL instance so it can seamlessly sync to cloud.

To be honest, I was planning to end the series here, but I have a few supporters on Patreon and it provided a surprising amount of motivation to keep going. I have articles on the progressive web app up on Patreon and a few more in mind. Please consider sponsoring me if you appreciate the content!