Using IndexedDB with React (and Hooks)
If you're new in React and you want to build an app with IndexedDB as your database, you're on the right page. In this article, we'll be looking at building a very small application which will add and remove items from an IndexedDB database, and updating our component state whiles our database changes. The purpose is mainly to help you understand how to work with data offline, especially when working on Progressive Web Apps. So, let's get started!
What will we build?
We'll be building a simple restaurant menu app with different meals. We'll only add the functionality to add and remove meals from the menu. Here's the sandbox of the app:
I've divided the process into 5 main parts:
- Setting up the app with
react-parcel-app-boilerplate
on GitHub. - Creating a
MenuContextProvider
andMenuContext
to handle the IndexedDB database, which will have only one object store,menu
. - Creating
Menu
andMeal
components. - Implementing the
removeMeal
feature. - Implementing the
addMeal
feature.
Important Note: This isn't an introductory article to React and IndexedDB. I'm assuming you are familiar with both technologies, and that you're here to learn how to use both side-by-side.
Setting up the Project
So, react-parcel-app-boilerplate
is my recent repository on GitHub meant to help set up React for immediate prototyping. So, instead of using create-react-app
which I think is too bulky, as compared to this boilerplate, we'd be using react-parcel-app-boilerplate
for simplicity by taking advantage of Parcel, the zero-configuration web application bundler.
Open your command-line program and navigate to the directory in which you want to create the app, and, assuming you already have Git installed, run the following command:
git clone https://github.com/gyenabubakar/react-parcel-app-boilerplate
Navigate into the project directory:
cd react-parcel-app-boilerplate
Now, assuming you're using NPM, run:
npm start
... and wait for Parcel to bundle the project and spin up a local development server, opening your default browser automatically.
In the App.jsx
file in the src/
folder, remove the unnecessary code so it becomes as simple as this:
import React, { Suspense } from "react";
// Suspense for loading effect
function App() {
return (
<>
</>
);
}
export default App;
You should clean the css/styles.css
file too.
Creating a MenuContextProvider
The reason I'm choosing to use context is because I want to make a centralised store, on which the Menu
and Meal
components can all depend when accessing the IndexedDB menu
object store. We'll create those components soon.
- In the
src/component/
directory, create a file namedMenuContextProvider.jsx
, with the code:
import React, { useState } from 'react';
function MenuContextProvider() {
return ();
}
export default MenuContextProvider;
- Download this repository:
tiny-indexed-db
, and copy theunique-string.js
andtiny-idb.js
into a new folderlib
in thesrc/
directory.
unique-string.js
is used to generate a unique string, which will be used as the ID of meals in themenu
object store of our IndexedDB database we're yet to create. You could could use UUID if you want, but I'll be usingunique-string.js
.
tiny-idb.js
is a small Promise-based library on top of thewindow.indexedDB
object, instead of using the traditionalonsuccess
andonerror
events handlers. Again, you can useidb
if you prefer it.
Creating a new indexedDB
Database
Inside the MenuContextProvider.jsx
file we just created, just after you've imported React, { useState }
, import the DB
object and UniqueString
class from the tiny-idb.js
and unique-string.js
files, respectively, from the src/lib/
folder. We'll be using them for our indexedDB
database in our app. In the MenuContextProvider.jsx
file, after you've made your imports, create the setUpDatabase
function:
import DB from '../lib/db';
import UniqueString from '../lib/unique-string';
// an instance of the UniqueString class,
// for generating unique strings,
// which we'll use as ID for the data to be stored
const uid = new UniqueString();
// this will initialize the database if it isn't set up yet
async function setUpDatabase() {
"use strict";
// create a DB with the name MenuDatabase and version 1
await DB.createDB("MenuDatabase", 1, [
// list of object stores to create after the DB is created
{
// name of first object store
name: "menu",
// keyOptions of the object store
config: { keyPath: "mealID" },
// list of data (meals) to store after store is created
data: [
{ mealID: uid.generate(), name: "Jollof Rice" },
{ mealID: uid.generate(), name: "Banku with Okro Stew" },
],
},
]);
}
I think the code above is self explanatory, but let me brush through. We're using a promise-based syntax with indexedDB
thanks to the tiny-idb.js
library.
- The
DB.createDB()
method creates anindexedDB
database with the nameMenuDatabase
, at version1
. The third argument, which is an array, is an array of objects defining a number of object stores to be created once the database is created. In our case, we create one object store with namemenu
, which usesmealID
property to uniquely identify an object added to the store, defined with theconfig
property. Thedata
property is an array of objects that should be added to the object store once it is successfully created. Here we have two objects each with different names and unique IDs generated byuid.generate()
. Please note that, thedata
array was added for testing purposes. If you use it and later empty your object store, when the page is loaded again, the objects defined will be added again. Please read the description of both libraries being used.
We'll call this function at the appropriate time.
Creating a MenuContext
Before we go ahead and create our context, we'll need a suspender, for data fetching. You can implement yours, or you could copy and paste this in a file called suspender.js
in the src/lib/
directory:
const promiseSuspender = (wrappedPromise) => {
let status = "pending";
let result = null;
const suspender = wrappedPromise
.then((resolvedResult) => {
status = "success";
result = resolvedResult;
})
.catch((error) => {
status = "error";
result = error;
});
return {
read() {
switch (status) {
case "pending":
throw suspender;
case "error":
return result;
default:
return result;
}
},
};
};
const suspender = (promise) => {
return {
data: promiseSuspender(promise),
};
};
export default suspender;
And, please import suspender
in the MenuContextProvider.jsx
file:
import suspender from "../lib/suspender";
After creating the setUpDatabase()
function, write the following line of code to create a context:
const MenuContext = React.createContext();
Now, let's re-write our MenuContextProvider
component:
// function to get all meals in the DB
async function getAllMealsFromDB() {
// now, initialise the database
await setUpDatabase();
// open the database & grab the database object
let db = await DB.openDB("MenuDatabase", 1);
// create a transaction on the db
// and retrieve the object store
const menuStore = await DB.transaction(
db, // transaction on our DB
["menu"], // object stores we want to transact on
"readwrite" // transaction mode
).getStore("menu"); // retrieve the store we want
// grab all meals from the menuStore
let allMeals = await DB.getAllObjectData(menuStore);
// return the allMeals array
return allMeals;
}
// using suspender to get all meals from database
const resource = suspender(getAllMealsFromDB());
// The component itself
function MenuContextProvider({ children }) {
// reading data from suspender
const meals = resource.data.read();
// state to store list of meals in the object Store
// set state to be the data retrieved from database
const [mealsList, setMealsList] = useState(meals);
// return the context provider passing the mealsList state
// and its updator function as values of the context
return (
<MenuContext.Provider value={{ mealsList, setMealsList }}>
{children}
</MenuContext.Provider>
);
}
// export MenuContext also
export { MenuContext };
Once again, self-explanatory code.
Creating Menu
and Meal
Components
So, before we go ahead and use our MenuContextProvider
, let's ensure we have the the Menu
and Meal
components implemented. The Menu
component will be displaying the list of meals in the database whilst the Meal
component will be the JSX representing each meal. Create two files Menu.jsx
and Meal.jsx
in the src/components
directory.
The Menu
Component
In your Menu.jsx
file, implement the component like so:
import React, { useContext } from "react";
import { MenuContext } from "./MenuContextProvider";
// calling in the MenuContext
import Meal from "./Meal"; // we'll create this component soon
function Menu() {
// grabbing the mealsList list from the MenuContext
// which has access to to the data in the MenuContextProvider state
// in the future
const { mealsList, setMealsList } = useContext(MenuContext);
// an array to store JSX of all meals in DB
const mealsOnMenu = mealsList.map((meal) => {
// return a Meal component for each meal object in DB
return <Meal key={meal.mealID} meal={meal} />;
});
// render list of all meals
return <div className="menu">{mealsOnMenu}</div>;
}
export default Menu;
The Meal
Component
Let's define our Meal
component: in the Meal.jsx
file inside the src/components
directory, insert this code:
import React from "react";
// grabbing the meal object passed from the context
// as a prop
function Meal({ meal }) {
// return a JSX representation of the data.
// we gave the outer DIV an id, set to the mealID
// of the meal object in the object store.
// we'll use this to query the object store later.
return (
<div className="meal" id={meal.mealID}>
<div className="meal-info">
<p>{meal.name}</p>
</div>
<div className="meal-actions">
<button className="btn-remove">Remove</button>
</div>
</div>
);
}
export default Meal;
Now, we can preview our app. But before that, we need to re-write our App.jsx
file:
import React, { Suspense } from "react";
import MenuContextProvider from "./components/MenuContextProvider";
import Menu from "./components/Menu";
function App() {
return (
<>
// show "Loading..." whiles we load data from database
<Suspense fallback={<p>Loading...</p>}>
<MenuContextProvider>
<Menu />
</MenuContextProvider>
</Suspense>
</>
);
}
export default App;
Please note that, Suspense is currently an experimental feature in React. At any moment, the React team could change its API and that'll mean, applications using it will break, including what we're building. I'm using it because, like the Suspense API, our app is also experimental.
Okay. Go back to your command-line; if you have terminated the development server, run npm start
again to see the app. It should look like this:
It looks ugly. We'll write some CSS later to polish it.
Implementing the removeMeal
Feature
Each Meal
component has a button that says "Remove", but at the moment we can't remove anything. That's about to change.
The Menu
component has its parent JSX element to be a DIV with a menu
class. We'll write a function that handles a click event on that DIV. Wait, why not the <button>
? Our database may contain a hundred Meal
components, each having a button. Adding an event listener on all of them is bad for performance. When a button is clicked, the event bubbles up. So, the button triggers the event, and then its parent <div className="meal-actions">
element, to the <div className="meal">
element, which also bubbles up to the <div className="menu">
, and on and on...
We'll take advantage of this and implement the event on the menu
element rather. So, in the Menu
component, we'll create a removeMeal
function inside the Menu
component which will perform the delete command. Since, it'll be the event handler, we'll use it to find the value of the id
attribute on the meal
element whose button was clicked.
Since, we'll be using the tiny-idb.js
library with indexedDB
, let's import it.
import DB from '../lib/tiny-idb.js';
Let's implement the removeMeal
function:
// destructure the target property from the event object
const removeMeal = async ({ target }) => {
// check if the target is a button element
// with an class of of "btn-remove"
let isRemoveButton =
target.tagName === "BUTTON" && /btn-remove/g.test(target.className);
if (isRemoveButton) {
// if true, get the id of its grandparent element ;)
const mealElement = target.parentElement.parentElement;
let id = mealElement.getAttribute("id");
// open the database
const db = await DB.openDB("MenuDatabase", 1);
// create an indexedDB transaction and grab the object store
const menuStore = await DB.transaction(
db,
["menu"],
"readwrite"
).getStore("menu");
// pass in the targeted ID and delete it from the database
await DB.deleteObjectData(menuStore, id);
}
};
Attach an onClick
event to the menu
DIV; in the Menu
component, return this JSX rather:
return (
<div className="menu" onClick={(e) => removeMeal(e)}>
{mealsOnMenu}
</div>
);
When the button is clicked, you don't see the UI change because we haven't updated the state of the MenuContextProvider
. However, the update appears in the database. In Chrome, check it by going to developer tools (press F12
) > Application tab > IndexedDB (on the left side-nav) > MenuDatabase > menu.
As you can see, after I clicked on the button of the meal with name "Banku with Okro Stew", it was removed from the database.
So, how do we fix this? Simple! The DB.deleteObjectData()
method that we used returns an array containing two objects:
- the deleted item, and
- an array of the remaining items in the object store.
So, in our removeMeal
function, instead of this line:
await DB.deleteObjectData(menuStore, id);
... we'll write this:
// destructuring the returned array
const [deleted, remaining] = await DB.deleteObjectData(menuStore, id);
Now that we have the remaining meals in the menu
object store, we can update the state of our MenuContextProvider
, which should cause the MenuContextProvider
component to re-render, and since the Menu
component is nested in it, it should also re-render, getting provided with the updated state.
Do you remember we passed the setMealsList
function (which updates the mealsList
state of the context) along with the mealsList
in the value of the context provider? Here's that line:
<MenuContext.Provider value={{ mealsList, setMealsList }}>
The setMealsList
function is used to update the state of the context. Do you also remember we destructured it when we used the useContext
hook, in the Menu
component?
const { mealsList, setMealsList } = useContext(MenuContext);
It's time to give it a purpose. Just after the line in the removeMeal
handler where we delete the meal object from the object store, write this code to update the context state:
// update context state with remaining objects in object store
setMealsList(remaining);
Now, when a button is clicked, the UI is updated, but don't celebrate yet. Try deleting all the items in the database. Refresh the page. You realise that the meals we just deleted have been added again. Well, that's because when we were setting up the database, we told indexedDB
to automatically add two meals to the menu
object store. Do you remember we set up some initial objects to be added once the database is set, in the setUpDatabase()
function, inside the MenuContextProvider.jsx
file:
// create a DB with the name MenuDatabase and version 1
await DB.createDB("MenuDatabase", 1, [
// list of object stores to create after the DB is created
{
...
// list of data (meals) to store after store is created
data: [
{ mealID: uid.generate(), name: "Jollof Rice" },
{ mealID: uid.generate(), name: "Banku with Okro Stew" },
],
},
]);
This happens because the tiny-idb
library checks whether or not, there are objects in the database with the same mealID
as the ones it's about to add, if there are, it skips and doesn't add them, else it goes ahead and adds them to the object store. So, when you delete all the objects from the store, and then reload the page, the initialisation process happens again. How do we handle this? We'd have to remove the initial data that'll be added when the page is loaded, but we'll do that after we add the addMeal
feature.
Let's do that!
Implementing the addMeal
feature
Before we begin writing some logic, let's first of all, create another component file, AddMeal.jsx
, in the src/components
folder. We'll create a form for the user to input data
import React, { useState } from "react";
function AddMeal() {
// state for the input field in the form
const [mealName, setMealName] = useState("");
// handler for when input field value changes
const handleMealNameChange = (e) => {
// value of input field
const value = e.target.value;
// update mealName state with the new value in input field
setMealName(value);
};
return (
// a form to be used to add the meal
<form className="add-meal">
{/* input field to keep track of the name of the new meal */}
<input
type="text"
id="mealName"
required
placeholder="Type meal name..."
// bind the value to the mealName state
value={mealName}
// when value changes call this function
onChange={handleMealNameChange}
/>
{/* a submit button */}
<input type="submit" value="Add Meal" />
</form>
);
}
export default AddMeal;
Import AddMeal
in App.jsx
and put the <AddMeal />
component in the <MenuContextProvider>
component, on top of the <Menu />
component:
import React, { Suspense } from "react";
import MenuContextProvider from "./components/MenuContextProvider";
import Menu from "./components/Menu";
import AddMeal from "./components/AddMeal";
function App() {
return (
<>
<Suspense fallback={<p>Loading...</p>}>
<MenuContextProvider>
<AddMeal /> {/* like this */}
<Menu />
</MenuContextProvider>
</Suspense>
</>
);
}
export default App;
In order to add the new meal to the MealContext
and update the whole UI, we need to add a submit event listener to the <form>
element. Then, we can write our logic to add the new meal to the database. To do this, let's import some modules:
import { MenuContext } from './MenuContextProvider';
import UniqueString from '../lib/unique-string.js';
import DB from '../lib/tiny-idb.js';
Okay, inside the AddMeal
component, we need to get the function that updates the context state. We can access it by using the useContext
hook from the MenuContext
. Remember to import { useContext } from 'react'
.
// getting the context state updator function
// we won't need the mealsList state itself
// so we're ignoring it with the underscore (_)
const { _, setMealsList } = useContext(MenuContext);
Ensure that the code above is written inside the AddMeal
component.
Now, create this onsubmit handler in the AddMeal
component:
// the form submit handler
async function handleAddNewMeal(e) {
// prevent the page from loading when form is submitted
e.preventDefault();
// open Database
const db = await DB.openDB("MenuDatabase", 1);
// create a transaction and grab the menu store
const menuStore = await DB.transaction(
db,
["menu"],
"readwrite"
).getStore("menu");
// add the data, and grab the updated meals
const newMealsInStore = await DB.addObjectData(menuStore, {
// set a unique ID
mealID: new UniqueString().generate(),
// set name to be value of mealName state
name: mealName,
});
// set the context state
// to have the updated items in menu store
setMealsList(newMealsInStore);
// after updating the context state, reset the input field's value
setMealName("");
}
Attach an onSubmit
event listener on the form and assign it to the handleAddNewMeal
handler we just created:
<form className="add-meal" onSubmit={handleAddNewMeal}>
Go ahead and test it. Yep, it works! :D
Before I conclude this article, let's go back inside the MenuContextProvider.jsx
file and edit the setUpDatabase()
function. Remove the data
array from the object that initialises the menu
object store.
return await DB.createDB("MenuDatabase", 1, [
// list of object stores to create after the DB is created
{
// name of first object store
name: "menu",
// keyOptions of the object store
config: { keyPath: "mealID" }
},
]);
Open the developer tools and delete the entire database, then refresh the page to initialise the database again. This time, we won't have any objects in the database when rendering for the first time:
A blank menu list? Yikes! Let's create a fallback which will be rendered if there are no meals in the menu store. Do this in the Menu.jsx
file.
function NoMealsFallback() {
return (
<div className="fallback">
<h1>:(</h1>
<h3>There are no meals on your menu.</h3>
<p>Use the form above to add some.</p>
</div>
);
}
In the Menu
component, instead of rendering this:
// render array of all meals
return (
<div className="menu" onClick={(e) => removeMeal(e)}>
{mealsOnMenu}
</div>
);
... let's conditionally render it only if there's a meal on the menu.
// if mealsList is empty render fallback
// else render the menu
return mealsList.length === 0 ? (
<NoMealsFallback />
) : (
<div className="menu" onClick={(e) => removeMeal(e)}>
{ mealsOnMenu }
</div>
);
At the moment, our app looks horrible. Let's add some stylesheets in the src/css/styles.css
file:
body {
font-family: Cambria, Georgia, serif;
font-weight: 200;
margin: 0;
}
.add-meal {
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
padding: 20px 0;
background: rgb(245, 245, 245);
margin-bottom: 20px;
}
.add-meal * {
font-family: Cambria, Georgia, serif;
font-weight: 200;
}
.add-meal input {
padding: 10px 15px;
background: rgb(241, 241, 241);
border: 0;
outline: 1px solid rgb(224, 224, 224);
font-size: 1.2rem;
box-sizing: border-box;
}
.add-meal input[type="text"] {
margin-right: 5px;
}
.add-meal input[type="text"]:focus {
background: #fff;
}
.add-meal input[type="submit"] {
background: rgb(66, 66, 66);
color: #fff;
cursor: pointer;
}
.add-meal input[type="submit"]:hover {
background: rgb(82, 81, 81);
}
.fallback {
outline: 1px solid rgb(224, 224, 224);
max-width: 400px;
text-align: center;
padding: 50px 0;
margin: 10% auto;
}
.fallback h1,
.fallback h3 {
color: rgb(66, 66, 66);
}
.fallback h1 {
margin: 0;
}
.menu {
padding: 20px 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
align-content: center;
}
.meal {
font-size: 1.2rem;
display: flex;
justify-content: space-between !important;
align-items: center;
min-width: 70% !important;
padding: 25px;
background: rgb(231, 231, 231);
align-self: center;
}
.meal:nth-child(odd) {
background: rgb(247, 247, 247);
margin-bottom: 10px;
}
.btn-remove {
padding: 10px 15px;
border: 0;
outline: 1px solid rgb(224, 224, 224);
font-size: 1rem;
box-sizing: border-box;
background: rgb(66, 66, 66);
color: #fff;
cursor: pointer;
}
/* animation for when a meal is removed */
.remove-meal {
animation: removeMeal 0.3s linear;
animation-fill-mode: forwards;
}
@keyframes removeMeal {
from {
min-width: 30%;
opacity: 0.5;
}
to {
min-width: 10%;
opacity: 0;
}
}
The styles.css
file has already been imported in the index.jsx
file, so there's no need to re-import it.
You can see that getting to the end of the styles.css
file, we've defined an animation for when a meal element is being removed from the DOM. This won't work yet. In our removeMeal()
function, in the Menu
component, just before we update the context state with the new data (which unmounts the element immediately from the DOM):
// update context state with remaining objects in object store
setMealsList(remaining);
... our animation plays in 300 milliseconds (0.3 seconds), so in order to see the animation in action, we have to delay the state update for 300 milliseconds. So, in 300 milliseconds, our little animation will play and fade out the removed element. Then, after that, we'll go ahead and update the context state. setTimeout()
will help us delay the state update:
// animate the meal element
// we defined the mealElement already,
// to contain the grandparent of the button which was clicked
mealElement.classList.add("remove-meal");
// after 300 milliseconds (0.3 seconds)
// update context state with remaining objects in object store
setTimeout(() => setMealsList(remaining), 300);
Remember to do this in the removeMeal()
function inside the Menu
component. Here's how our app now looks:
Menu app with no meals
Menu app after meals have been added
Here's the complete sandbox of the toy app we just created:
Task: Add another button and use it to update already added meals individually. You can also add a remove-all-meals button to clear all the meals from the data store.
In case you find it hard working with my tiny-idb.js
library, make sure to check out its README.md
file on the GitHub repo and learn more about it.
If you have any ideas and you'd want to add some features to the react-parcel-app-boilerplate
or tiny-indexed-db
, feel free to contribute.
If you like this article, please react to it, share, and follow me on Hashnode and or Twitter, and comment if you have any questions.
Brazilian full stack developer, tale writer and aspiring designer
I'm a React enthusiast. So, for a future guide in new self challenges, I bookmarked this amazing article. Thanks for sharing!
Comments (2)