Caching static file-based resources with the Cache API so that those static pages are navigable without an internet connection is what most people think progressive web apps (PWAs) are all about. However, PWAs can be capable of so much more with browser storage. With browser storage, we can build fully functional interactive offline apps that can:
For such needs, browsers provide two main storage mechanisms that are persistent and accessible offline: localStorage and IndexedDB.
However, these two have a couple of shortcomings.
On one hand, localStorage is very easy to use with its simple get-and-set API. But, it can only store string type data. This means other data types have to be converted to strings with JSON.stringify()
when storing and then converted back with JSON.parse()
when reading from the storage, which is not very secure. Also, localStorage offers a very limited storage size of about 5MB, is synchronous and not accessible from web workers, and so does not support background syncing.
IndexedDB, on the other hand, is almost exactly what we need. It is asynchronous and therefore doesn’t block the main thread. It accepts different data types including blobs, files, and images, has a higher storage limit of sometimes up to 1GB depending on the user’s disk space and operating system, and can be accessed by web workers.
But, working with the native indexedDB API is a total nightmare. You’ll sometimes have to write over 10 lines of code just to store data.
This is where localForage comes in. (If you’re new to the concept of offline storage, I suggest you read this before moving on).
In the words of its creators, localForage is offline storage improved. It makes working with offline storages easier by conveniently providing an abstraction layer over them. It combines the flexibility of indexedDB with a simple asynchronous localStorage-like API. This means that we get to use the async-await syntax that we’ve all come to love.
localForage is designed to store data in indexedDB and fall back to localStorage if there is no support for indexedDB. (Although, this may have considerable performance and storage side effects, as all data will be serialized on save, and only data that can be converted to a string via JSON.stringify()
will be saved).
Because indexedDB is currently supported by all major browsers, and, if you’re building a PWA, older browsers will not support most of the features you’ll need to work with, you can outrightly decide to support only modern browsers.
At my current company, we support only Chrome for our PWA product, which is an all-in-one SaaS platform for small businesses. We use localForage to store data that should be available regardless of internet connection, because the app is mainly used by sales agents, who may sometimes be in unconventional places with poor internet connection.
For the rest of this article, I’ll walk you through how to setup localForage and perform basic CRUD actions with it, just like with any other database. We’re going to write functions to build out parts of a hypothetical CRM sales app used by sales reps to collate prospective customer contact details—often in places where connectivity may be a problem. Let’s get started.
First, let’s set up a simple HTML page with a form for collecting customer data and a table to show all customer details:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>LocalForage Demo</title> </head> <body> <div> <label>Client Name: <input id="clientName" type="text" name='clientName' value=""> </label> <label>Phone Number: <input id="clientPhone" type="number" name='clientPhone' value=""> </label> <label>Needs: <input id="clientNeed" type="text" name='clientNeed' value=""> </label> <button type="submit" id="submit">Save</button> </div> <div> <table> <thead> <tr> <th>Client Name</th> <th>Phone Number</th> <th>Needs</th> <th>Actions</th> <th>Actions</th> <th>Actions</th> </tr> </thead> <tbody> </tbody> </table> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/localforage/1.9.0/localforage.min.js"></script> <script src="index.js"></script> </body> </html>
To keep this tutorial framework and tooling agnostic, we’ll be using localForage via a CDN link but you can choose to install it via npm or yarn.
Next, let’s configure our offline database a bit:
//in index.js const ContactTable = localforage.createInstance({ name: "CRMApp", storeName: "ContactTable" });
Here, we’re using the localForage createInstance method to create a new database (CRMApp
) and also a store (ContactTable
) for our app. A store in localForage is just like an object store in indexedDB, which can be thought of as an individual table in a database.
It is ideal to have different stores for different categories of data you wish to store. If you don’t do this, localForage will by default set up a database with the name “localforage” and a store with a name of “keyvaluepairs,” where it will store all your data for you.
Now that we’ve done the basic setup, let’s get into writing CRUD functions. When a sales rep enters new customer details into the inputs and clicks the save button, for example, we need to get values off all inputs and structure it into an object.
const getClientDetails = () => { const clientName = document.getElementById("clientName").value; const clientPhone = document.getElementById("clientPhone").value; const clientNeed = document.getElementById("clientNeed").value; return { clientName: clientName, clientPhone: clientPhone, clientNeed: clientNeed, } }
Then, we need to store the new customer details object in the browser storage and display it on the table:
const addInput = async () => { const inputValues = getClientDetails(); const dbLength = await ContactTable.length(); let id = dbLength === 0 ? 1 : dbLength + 1; try{ let row = `<tr id="${id}"> <td>${inputValues.clientName}</td> <td>${inputValues.clientPhone}</td> <td>${inputValues.clientNeed}</td> <td><button class="edit">Edit</button></td> <td><button class="delete"> Delete</button></td> </tr>` document.querySelector('tbody').insertAdjacentHTML('afterbegin', row); await ContactTable.setItem(id, inputValues); alert('contact added successfully'); }catch(e){ console.log(err.message); }} document.getElementById('submit').addEventListener('click', async(e) => { e.preventDefault(); await addInput(); })
With the addInput
function, when the save button is clicked, we call the getClientDetails
function to get the customer detail object and then use ES6 template literals to add a new row containing those details to the top of the HTML table.
We’re also using localForage’s length method to generate a unique ID, which will be used to retrieve individual entries. Lastly, we use the setItem method to add the customer detail object to the contactTable
store.
You can open the application pane of chrome dev tools to see it in action:
Next, we need to be able to read all customer details data and display it on the table whenever the app is refreshed, reloaded, or opened on another tab:
const loadContactsFromStorage = async () => { try { await ContactTable.iterate((value, key, iterationNumber) => { let newContact = `<tr id="${key}"> <td>${value.clientName}</td> <td>${value.clientPhone}</td> <td>${value.clientNeed}</td> <td><button class="edit">Edit</button></td> <td><button class="delete"> Delete</button></td> <td><button class="view">View</button></td> </tr>` document.querySelector('tbody').insertAdjacentHTML('afterbegin', newContact); }) } catch (err) { console.log(err) } } window.addEventListener('load', async() => { await loadContactsFromStorage(); })
Here we use the localForage iterate method to loop through all entries in the offline database. The iterate
method takes a callback function which it calls on every iteration, just like the ES6 map
function.
This callback function receives the value and key of the current data in iteration and iterationNumber as arguments. We use template literals to add the values of each object key to the table. After that, we listen for the load event and call loadContactsFromStorage
.
This is a good place to check if data exists in the store using the length property—in case of a user accessing the app via a new device—and then attempt to load data from the remote database and store it in the browser storage for offline access.
The next feature is to give sales reps the ability to delete a customer’s contact details from the table. This means we need to remove the contact from the app interface as well as from the offline database when the delete button is clicked.
const deleteContact = async(e) => { const row = e.target.parentElement.parentElement; const key = row.id; row.remove(); try{ await ContactTable.removeItem(key); alert('Contact deleted successfully'); }catch(err){ console.log(err.message); } } window.addEventListener('load', async() => { await loadContactsFromStorage(); document.querySelectorAll('.delete').forEach(button =>{ button.addEventListener('click', async(e) => { await deleteContact(e); }) }); })
Here, we’re using the HTML parentElement
attribute to get the row that contains the clicked delete button. Then, we use the HTML remove
method to remove that row from the UI.
To remove from offline storage, we use the localForage removeItem method, passing in the ID of the target row as a key. We also attach click event listeners to all delete buttons within the load event underneath the loadContactFromStorage
function, because the rows have to be loaded into the DOM first before event listeners can be attached.
It’s very possible that a customer’s phone number or needs may change. We want to give sales reps the ability to edit and update customers’ details, even if they’re offline. To achieve this, when the edit button is clicked, we need to show a modal which will have a form with inputs containing target individual customer details and an update button. When the update button in the modal is clicked, we then need to update the target customer detail entry in the offline database and also on the table.
Let’s first add a modal to the HTML:
<div id="modal"> <div class="backdrop"> <div class="form"> <label><span>Client Name:</span> <input id="editName" type="text" name='editName' value=""> </label> <label><span>Phone Number:</span> <input id="editPhone" type="number" name='editPhone' value=""> </label> <label><span>Needs:</span> <input id="editNeed" type="text" name='editNeed' value=""> </label> <button type="submit" id="update">Update</button> </div> </div> </div>
Let’s also add some CSS to style the modal and hide it on initial load:
css #modal{ position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; display: none; } #modal .backdrop{ background-color: rgba(43, 40, 40, 0.5); width: 100vw; height: 100vh; } #modal .form{ background-color: #fff; padding: 40px; border: 1px solid blue; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 1; }
Now, let’s write a function to handle the first part of the update task, which is to retrieve info when the edit button is clicked:
let updateKey; const viewContact = async(e) => { updateKey = e.target.parentElement.parentElement.id; const editName = document.getElementById("editName"); const editPhone = document.getElementById("editPhone"); const editNeed = document.getElementById("editNeed"); try{ const contact = await ContactTable.getItem(updateKey); editName.value = contact.clientName; editPhone.value = contact.clientPhone; editNeed.value = contact.clientNeed; document.getElementById("modal").style.display= "block"; }catch(err){ console.log(err.message); } } window.addEventListener('load', async() => { await loadContactsFromStorage(); .... document.querySelectorAll('.edit').forEach(button =>{ button.addEventListener('click', async(e) => { await viewContact(e); }) }) })
Here, we create a global variable: updateKey
because we’ll make use of it in multiple functions. The viewContact
function gets the ID of the table row which contains the clicked button.
Then, it uses the localForage getItem method to retrieve values from a single key-value pair entry matching the supplied key from the offline database. It then displays the returned values as values of the modal inputs and sets the modal display to block.
We’ve also attached click event listeners to all edit buttons within the load event for the same reason as in the previous step.
Next, we need to write a function to take care of the second part of the update task which happens when the update button on the modal is clicked:
const updateContact = async() => { try{ const updatedClient ={ clientName: document.getElementById("editName").value, clientPhone: document.getElementById("editPhone").value, clientNeed: document.getElementById("editNeed").value, } let updatedRow = `<tr id="${updateKey}"> <td>${updatedClient.clientName}</td> <td>${updatedClient.clientPhone}</td> <td>${updatedClient.clientNeed}</td> <td><button class="edit">Edit</button></td> <td><button class="delete"> Delete</button></td> </tr>` document.getElementById(`${updateKey}`).remove(); document.querySelector('tbody').insertAdjacentHTML('afterbegin', updatedRow); await ContactTable.setItem(updateKey, updatedClient); document.getElementById("modal").style.display= "none"; alert('Contact updated successfully'); }catch(err){ console.log(err.message); } } document.getElementById('update').addEventListener('click', async(e) => { e.preventDefault(); await updateContact(); })
The updateContact
function gets the updated customer’s details from the modal inputs and then forms an HTML table row with those details using template literals. Then it appends the created row to the beginning of the table after deleting the row with the previous data from the table.
Whew! Give yourself a pat on the back. You’ve successfully built a fully functional offline CRUD app using localForage.
There were some repetitions in our code in order to aid understanding. You can attempt to DRY it out. Also, you can take a look at the full codebase on this github repo and check out a live demo here.
The next step is to implement background syncing into the storage flow. Depending on the remote database you decide to use, your mode of approach will differ. Not to worry, I’ve added some articles below to help you get started.
If you’d like to see a demo project with all the concepts discussed here in action, check out this expense and income tracker PWA that I built a while ago with indexedDB. It works fully offline.
Take a look at the controller.js file, line 56 and sw.js, line 98 to see how I implemented background syncing to a remote firebase database.
In this article, I introduced you to the power and incredible usefulness of browser offline storages and how the localForage library makes working with and managing them a lot easier. I also showed you how to perform basic CRUD functions on browser storage using localForage. Now, go build something awesome with this newly gained knowledge!
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]