DEV Community

Cover image for IndexedDB, your offline and serverless DB in your browser with React
Fernando González Tostado
Fernando González Tostado

Posted on • Updated on

IndexedDB, your offline and serverless DB in your browser with React

Some months ago I stumbled with this inherited new app in my company.

This app had some awesome and impressive features where I learned —and struggled ☠️— a ton of features that I didn't understand very well —or haven't heard of at all!

One of these cool features was using an IndexedDB for storage.

Honestly it took some time to understand where the hell the data was being stored. I thought that it was fetched everytime or cached with some sort of server magic that was beyond my reach. However, after diving deeper in the code I discovered 💡 that it was using an indexedDB to store the data in the browser, therefore saving a lot of unnecessary requests.

So what's an Indexed DB?

"IndexedDB is a low-level API for client-side storage of significant amounts of structured data, including files/blobs."

Key Concepts

  • Asynchronous, therefore it won't block your main thread operations ⚡.
  • A noSQL database, which makes it very flexible —and dangerous ☢️.
  • Lets you access data offline.
  • Can store a large amount of data (more than other web storage like localStorage or sessionStorage).

Okay, so once I had a better idea of what an IndexedDB was, I wanted to practice a bit with a simple implementation instead of having to painfully try it with the super complex codebase of my inherited real life application.

Implementation in a React App

This is a low-level API, therefore its implementation is kind of weird, but don't be scared, after some practice everything falls into its place. While there are some third app libraries to handle it —like Dexie—I wanted to try the raw API.

All the methods used here will return a Promise, this way we'll be able to get a response in our component of what's happening in the DB. If you wish, you don't have to wait for a promise to be resolved, it's fine and actually how you'll see the implementation in most pages out there. I like the promise approach for better understanding what's going on 👀 but this approach of course will block the main thread until the promise is resolved.

Initializing the DB

First step, declare the db and open (or init) it in the user browser

// db.ts

let request: IDBOpenDBRequest;
let db: IDBDatabase;
let version = 1;

export interface User {
  id: string;
  name: string;
  email: string;
}

export enum Stores {
  Users = 'users',
}

export const initDB = (): Promise<boolean> => {
  return new Promise((resolve) => {
    // open the connection
    request = indexedDB.open('myDB');

    request.onupgradeneeded = () => {
      db = request.result;

      // if the data object store doesn't exist, create it
      if (!db.objectStoreNames.contains(Stores.Users)) {
        console.log('Creating users store');
        db.createObjectStore(Stores.Users, { keyPath: 'id' });
      }
      // no need to resolve here
    };

    request.onsuccess = () => {
      db = request.result;
      version = db.version;
      console.log('request.onsuccess - initDB', version);
      resolve(true);
    };

    request.onerror = () => {
      resolve(false);
    };
  });
};
Enter fullscreen mode Exit fullscreen mode

This initDB method will basically open the connection myDB —we can have several DB's and this is the tag that identifies them— and then we'll attach two listeners to request.

The onupgradeneeded listener will only be fired when a) we create a new DB b) we update the version of the new connection with for example indexedDB.opn('myDB', version + 1).

The onsuccess listener will be fired if nothing went wrong. Here's where we will usually resolve our promise.
The onerror is self explanatory. If our methods are correct we'll rarely hear from this listener. However, while building our app it will be very useful.

Now, our component will show this super simple UI

import { useState } from 'react';
import { initDB } from '../lib/db';

export default function Home() {
  const [isDBReady, setIsDBReady] = useState<boolean>(false);

  const handleInitDB = async () => {
    const status = await initDB();
    setIsDBReady(status);
  };

  return (
    <main style={{ textAlign: 'center', marginTop: '3rem' }}>
      <h1>IndexedDB</h1>
      {!isDBReady ? (
        <button onClick={handleInitDB}>Init DB</button>
      ) : (
        <h2>DB is ready</h2>
      )}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

first ui before init

If we call the handleInitDB the DB will be created and we'll be able to see it in our dev tools/application/IndexedDB tab:

dev tools - indexed db table

Hooray! We have the DB up and running. The DB will persist even if the user refreshes or loses her connection 🔥.

Init ready

Btw, the styling is up to you 😂.

Adding data

For the moment we have an empty DB. We are now going to add data to it.

export const addData = <T>(storeName: string, data: T): Promise<T|string|null> => {
  return new Promise((resolve) => {
    request = indexedDB.open('myDB', version);

    request.onsuccess = () => {
      console.log('request.onsuccess - addData', data);
      db = request.result;
      const tx = db.transaction(storeName, 'readwrite');
      const store = tx.objectStore(storeName);
      store.add(data);
      resolve(data);
    };

    request.onerror = () => {
      const error = request.error?.message
      if (error) {
        resolve(error);
      } else {
        resolve('Unknown error');
      }
    };
  });
};
Enter fullscreen mode Exit fullscreen mode

What's new here?

No onupgradeneeded anymore. We don't make changes to the store version, therefore this listener is not required.

However, the onsuccess listener changes. This time we'll write on the db using the transaction method, which will lead us to be able to make use of the add method, where we are finally passing our user data.

Here Typescript helps us to avoid mistakes when passing our data and keeping our DB integrity intact by accepting a <T> generic type, which for this case will be the User interface.

Finally, in our onerror I didn't want to lose much time for this, it will resolve a string, but it could be an Exception or something similar.

In our component we'll add this simple form to add users.

// Home component
  const handleAddUser = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const target = e.target as typeof e.target & {
      name: { value: string };
      email: { value: string };
    };

    const name = target.name.value;
    const email = target.email.value;
    // we must pass an Id since it's our primary key declared in our createObjectStoreMethod  { keyPath: 'id' }
    const id = Date.now();

    if (name.trim() === '' || email.trim() === '') {
      alert('Please enter a valid name and email');
      return;
    }

    try {
      const res = await addData(Stores.Users, { name, email, id });
    } catch (err: unknown) {
      if (err instanceof Error) {
        setError(err.message);
      } else {
        setError('Something went wrong');
      }
    }
  };

// ...

{!isDBReady ? (
  <button onClick={handleInitDB}>Init DB</button>
) : (
  <>
    <h2>DB is ready</h2>
    {/* add user form */}
    <form onSubmit={handleAddUser}>
      <input type="text" name="name" placeholder="Name" />
      <input type="email" name="email" placeholder="Email" />
      <button type="submit">Add User</button>
    </form>
    {error && <p style={{ color: 'red' }}>{error}</p>}
  </>
)}
Enter fullscreen mode Exit fullscreen mode

add user form

If we add the user and go to our application (don't forget to refresh the database for the stale data) we'll see our latest input available in our IDB.

IDB with data

Retrieving data

Ok, we are almost there.

Now we'll get the store data so we can display it. Let's declare a method to get all the data from our chosen store.

export const getStoreData = <T>(storeName: Stores): Promise<T[]> => {
  return new Promise((resolve) => {
    request = indexedDB.open('myDB');

    request.onsuccess = () => {
      console.log('request.onsuccess - getAllData');
      db = request.result;
      const tx = db.transaction(storeName, 'readonly');
      const store = tx.objectStore(storeName);
      const res = store.getAll();
      res.onsuccess = () => {
        resolve(res.result);
      };
    };
  });
};
Enter fullscreen mode Exit fullscreen mode

And in our jsx component

// ourComponent.tsx
const [users, setUsers] = useState<User[]|[]>([]);

// declare this async method
const handleGetUsers = async () => {
  const users = await getStoreData<User>(Stores.Users);
  setUsers(users);
};

// jsx
return (
  // ... rest
  {users.length > 0 && (
  <table>
    <thead>
      <tr>
        <th>Name</th>
        <th>Email</th>
        <th>ID</th>
      </tr>
    </thead>
    <tbody>
      {users.map((user) => (
        <tr key={user.id}>
          <td>{user.name}</td>
          <td>{user.email}</td>
          <td>{user.id}</td>
        </tr>
      ))}
    </tbody>
  </table>
  )}
  // rest
)
Enter fullscreen mode Exit fullscreen mode

And et voilà! We see our data in the table and this data will persist in the browser 👏.

users table

Finally, to make this more dynamic we can make any of theses processes without all these buttons since we receive promises:

  const handleAddUser = async (e: React.FormEvent<HTMLFormElement>) => {
    // ...
    try {
      const res = await addData(Stores.Users, { name, email, id });
      // refetch users after creating data
      handleGetUsers();
    } catch (err: unknown) {
      if (err instanceof Error) {
        setError(err.message);
      } else {
        setError('Something went wrong');
      }
    }
  };
Enter fullscreen mode Exit fullscreen mode

Deleting rows

Finally, we'll understand how to delete data from the DB. This is quite easy, however, we'll have to have in mind which is our Key Path —a.k.a. Unique identifier or Primary Key— for the entries of the selected store. In this case we declared that the id will be that identifier

// db.ts/initDb
db.createObjectStore(Stores.Users, { keyPath: 'id' });
Enter fullscreen mode Exit fullscreen mode

You can also verify it directly by inspecting your store in the dev tools

identify your key path

For more advanced keys you can make use of other Key Paths, however we won't cover those cases here, so using the id as KP is the expected pick.

The method to delete the selected row will accept a storeName ('users' in this case), and the id as parameters.

export const deleteData = (storeName: string, key: string): Promise<boolean> => {
  return new Promise((resolve) => {
    // again open the connection
    request = indexedDB.open('myDB', version);

    request.onsuccess = () => {
      console.log('request.onsuccess - deleteData', key);
      db = request.result;
      const tx = db.transaction(storeName, 'readwrite');
      const store = tx.objectStore(storeName);
      const res = store.delete(key);

      // add listeners that will resolve the Promise
      res.onsuccess = () => {
        resolve(true);
      };
      res.onerror = () => {
        resolve(false);
      }
    };
  });
};
Enter fullscreen mode Exit fullscreen mode

Promises should always be handled with a try/catch block.

And in the component a handler to remove users, and of course a button to pick the desired element in the table

  const handleRemoveUser = async (id: string) => {
    try {
      await deleteData(Stores.Users, 'foo');
      // refetch users after deleting data
      handleGetUsers();
    } catch (err: unknown) {
      if (err instanceof Error) {
        setError(err.message);
      } else {
        setError('Something went wrong deleting the user');
      }
    }
  };

  // ...

  return (
    // ...
    {users.length > 0 && (
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Email</th>
            <th>ID</th>
            // header
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>{user.id}</td>
              // here the button
              <td>
                <button onClick={() => handleRemoveUser(user.id)}>Delete</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    )}
  );
}
Enter fullscreen mode Exit fullscreen mode

And the element should be gone after clicking the Delete button 🪄.

Conclusion

To be honest, I think that this API is weird and it feels a lack of implementation examples (for React at least) and documentation —besides a few great ones of course— and it should IMO be used only in a few specific cases. It's always nice to have options, and if it's your case, go ahead, I've witnessed a real production use case in an app that has thousands of users, and I must confess that the IDB has been the feature that has brought me the least trouble. This tool has been out there for several years already, so it's stable enough to be safely used.


If you want to check the full code, here's the repo.

Sources

MDN

Photo from Catgirlmutant on Unsplash

Extra

An interesting approach with promise chaining

Top comments (1)

Collapse
 
jarrisoncano profile image
Jarrison Cano

🔥🔥🔥🔥