September 13, 2023

Make your Rails app work offline. Part 3: IndexedDB and Stimulus

Alicia Rojas

This is the last part of a series about building a PWA (Progressive Web App) with Ruby on Rails. In parts 1 and 2, we put together the main pieces of software to allow our app to be installable, cache key assets, and provide an offline fallback. Now we are ready to get to the tofu (not the meat) of this project: allowing our application to perform Create-Read-Update-Delete (CRUD) actions while being offline.

The main ingredients

1. Browser storage

In a regular web application, working with data means interacting with a database that is hosted somewhere (i.e: in the cloud or your own server). When we create a record in a Ruby on Rails application, that object is stored in the database through ActiveRecord. When we work offline, we need to store those records locally, so we can later sync them with the server.

There are some browser storage tools that could do this for us, and they include: sessionStorage, localStorage, cookies, and IndexedDB.

IndexedDB is a JavaScript API for managing a database of JSON objects in the browser. I chose it because it has persistence across sessions and tabs, and it allows to store and search larger amounts of data in comparison to other mechanisms.

All IndexedDB operations (transactions) are made asynchronously, and consequently its programming syntax relies heavily on Promises. Things can get intricate very fast, so I recommend using a wrapper. Wrappers are JS libraries that allow a more programmer-friendly syntax for IndexedDB. Here is a list of some popular libraries, so you can chose the one that best fits your needs.

2. A sync mechanism

We can achieve synchronization with two methods: manual and background sync. Manual sync is triggered by the user through a UI element, like a button.

On the other hand, the Background Synchronization API allows web applications to defer server synchronization work to their service worker to handle at a later time, if the device is offline.

There are some important compatibility considerations when using this tool, especially if you have a large number of Safari/iOS users.
I recommend providing manual synchronization first and using background sync as an enhancement.

Both manual and background sync are powered by JavaScript code that will run in the client.

3. StimulusJS

StimulusJS is a simple yet powerful JavaScript framework that comes by default in Rails since version 7. We’ll be using Stimulus to store form data in IndexedDB, display it to the users, and perform AJAX requests to the server for manual synchronization.

The recipe

Step 1: check your network status

Unfortunately, knowing if an internet connection is available is not as trivial as calling window.navigator.onLine. CanIUse.com says about this:

Online does not always mean connection to the Internet. It can also just mean connection to some network.

Looking for a better solution, I came across this post, and implemented and expended version of theirs. It works like this:

  • Every N seconds I will attempt to fetch a 1-pixel image from the server. I made sure the service worker is not caching this image. This is done when registering your routes:
registerRoute(  
({request, url}) => request.destination === "document" ||
                     request.destination === "" &&                    
// we fetch this image to check the network status, so we exclude it from cache  
!url.pathname.includes("/assets/misc/white-pixel"),
new NetworkFirst({    cacheName: 'documents',  }))
  • Store value in localStorage.
    I will store the network status as a boolean in localStorage. This value will be the source of truth across tabs and sessions within the same browser.
  • I want to let the user know their network status, so I implemented a network toggle in the app front-end. If the user is online, the toggle will display “online”, but it can switch to “offline” as well.
    If the user is offline, only offline mode is available. This toggle will not only show the network status but will also define how the app behaves upon certain actions. For instance, records that are created when the toggle is off will be saved locally, and manual synchronization will not be allowed.

Note: Because I’m using these behaviors (checking the network status, declaring an IndexedDB database, setting/reading a value in localStorage) in more than one place of my app, I decided to use mixins to encapsulate this logic while avoiding repetition.

Step 2: store your records in IndexedDB

When the user is offline and attempts to submit a form, we will prevent a request to the server and instead save the form locally. This is done in a Stimulus controller. I like to keep my files organized so I created a directory for my PWA Stimulus controllers inside javascript/controllers . The specific syntax will depend on the IndexedDB wrapper you’re using, and the specific needs of your app, but generally it will look like this:

// app/javascript/controllers/pwa/form_controller.js
connect() {
// declare an indexedDB database, declare a boolean variable for the network status
  this.db = findOrCreateDB()
  this.onlineStatus = getOnlineStatusFromLocalStorage() === "true"
}

submit() {
// we check again in case it changed just before the submission
  this.onlineStatus = (this.getOnlineStatusFromLocalStorage() === "true" )
 if (!this.onlineStatus) { event.preventDefault() }
}

async saveFormData() {
  this.onlineStatus = (this.getOnlineStatusFromLocalStorage() === "true" )
 if (!this.formValid()) { return } // check if form is valid before saving it
 if (!this.onlineStatus()) {    
    // save record in IndexedDB  
 }
}

We need to make sure our front-end validations exactlymatch our back-end validations (a.k.a. model validations). Why? Because we want every record stored in IndexedDB to be a valid record in our server database. This will save us a lot of failed synchronization headaches.

Step 3: enable manual sync

We now have some records in our local (IndexedDB) database. Our user is back in the civilization and wants to sync their records.

Again, we will use Stimulus controller to do this. I connected the controller below to a “Sync” button in my records index


// app/javascript/controllers/pwa/sync_controller.js

connect() {
  // same as above, we declare a db and online status
}

async sync() {  
  if (await this.db.forms.count() == 0 && !this.connected) return
 
  const forms = await db.table('forms').toArray() // this is Dexie syntax
 const formsIdsToRemove = []
  await Promise.all(forms.map(form) = >{
   try {
     // make ajax request to your server      
      if (response.ok) {
       formsIdsToRemove.push(form.id)
     }
   } catch(error) {
     // handle error    
  }  
})  

// after looping through all records, remove synchronized forms from IndexedDB  
  for (let id of formsIdsToRemove) {
   await db.forms.delete(id)  
  }
}

Interacting with unsynced data (read and update)

When we save a record locally, it will live in IndexedDB until we perform a sync. But what if a user want to see (a.k.a. read) the record? or update it?
With server-side rendering, we’re not used to spend too much time thinking about how to display data, because Rails does it for us. We just need to have a controller action with its respective .html.erb file and Rails will populate all the attributes in the view.

However, these records don’t live in our database yet, and we are not connected to the server, so server-side rendering is not an option here.

We need another approach for this, one that I’ve creatively called “the template approach”.

For this example, we will build a simple list item in our index, so users can know which records are not yet synced.

First step is to write the content of our list item inside template tags. Template tags can hold content that will be hidden when the page loads. When the user is online, they will load the index view and the service worker will cache it, along with the hidden markup and the JS that will take care of render it when it is needed.


<%# app/views/forms/index.html.erb %>

<template data-pwa--index-target="listItemTemplate">
  <div data-list-item-dom-id="{{ dom_id }}" >
    <a href="">
      <p>{{ name }}</p>
    </a>
  </div>
  <%= inline_svg_tag 'icons/cloud-not-synced.svg', size: '20*20' %>
</template>

Then we will use Stimulus and Mustache to populate the template with data from IndexedDB and render it. Mustache is a JS library that renders HTML populating “mustache” variables with JSON objects.

// app/javascript/controllers/pwa/sync_controller.js

async displayOfflineForms() {
   const forms = await this.db.table('forms').toArray()
   forms.forEach(async(form) => {
     if (!this.listItemExists(form)) {
       this.listContainerTarget.innerHTML += (this.listItem(form))
     }      
      if (this.formExistsInServer(form)) {this.removeSyncedItem(form)}
   })
}

listItem(form) {
 const template = this.listItemTemplateTarget.innerHTML
 const rendered = Mustache.render(template, {
   name: form.name,
   dom_id: form.id
 })
 return rendered
}

We can enable an edit view with this same technique. You would need to add a template version of your form inside your index (or whatever view you want to access the record from), and provide an edit button in the record’s item element. This button would be connected to a stimulus controller that would render the template in the view when clicking it. We would reuse the Stimulus controller that we used to save changes in IndexedDB when submitting the form.

This behavior very much emulates a Single Page Application (SPA), with the advantage of doing it with minimum JS.

Final thoughts

PWA features can super charge your Rails application, letting you provide functionality under different network conditions. Since it is a rather new technology, there are lots of unsolved challenges and great room for creativity.
I hope this series of posts encouraged you to build your own Rails PWA. If you have any questions, drop me a message or comment below!

READY FOR
YOUR UPCOMING VENTURE?

We are.
Let's start a conversation.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Our latest
news & insights