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
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 title
s and
renders them with links to that title
. This component needs an extra layer
now because we are using id
s instead of title
s. 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 ofstate
- accept the recipe
id
instead oftitle
- accept the
addTag
callback instead ofdispatch
- 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 aNone
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 upsert
ed 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: true
in 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 thebuild
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!