Using IndexedDB with React (and Hooks)

Subscribe to my newsletter and never miss my upcoming articles

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:

  1. Setting up the app with react-parcel-app-boilerplate on GitHub.
  2. Creating a MenuContextProvider and MenuContext to handle the IndexedDB database, which will have only one object store, menu.
  3. Creating Menu and Meal components.
  4. Implementing the removeMeal feature.
  5. 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 named MenuContextProvider.jsx, with the code:
import React, { useState } from 'react';

function MenuContextProvider() {
    return ();
}

export default MenuContextProvider;
  • Download this repository: tiny-indexed-db, and copy the unique-string.js and tiny-idb.js into a new folder lib in the src/ directory.
  • unique-string.js is used to generate a unique string, which will be used as the ID of meals in the menu object store of our IndexedDB database we're yet to create. You could could use UUID if you want, but I'll be using unique-string.js.
  • tiny-idb.js is a small Promise-based library on top of the window.indexedDB object, instead of using the traditional onsuccess and onerror events handlers. Again, you can use idb 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 an indexedDB database with the name MenuDatabase, at version 1. 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 name menu, which uses mealID property to uniquely identify an object added to the store, defined with the config property. The data 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 by uid.generate(). Please note that, the data 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:

First preview of Menu App

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.

Chrome Developer Tools 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:

  1. the deleted item, and
  2. 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:

Empty menu

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>
);

No Meals Fallback

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 with no meals

Menu app after meals have been added 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.

Comments (2)

Luiz Filipe da Silva's photo

I'm a React enthusiast. So, for a future guide in new self challenges, I bookmarked this amazing article. Thanks for sharing!

Gyen Abubakar's photo

Glad it was helpful, Luiz!