Accessing RxDB from React Components in Rescript
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.
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.
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 idto 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 recipesinstead ofstate
- accept the recipe idinstead oftitle
- accept the addTagcallback instead ofdispatch
- forward the appropriate values to the displayRecipefunction
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 aNoneoption
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 buildto create production artifacts
- cdinto the- builddirectory
- 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!