IndexedDB, your offline and serverless DB in your browser with React
Tags: react, serverless, database, typescript
Published: 2023-01-13 20:23:29

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

```ts
// 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);
    };
  });
};
```


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

```tsx
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>
  );
}
```

![first ui before init](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/25h8354p8tevystwvxhi.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/loyy4pmwynq1quvjakxf.png)

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

![Init ready](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q0s6ysd7y8mef91zuth5.png)

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.

```ts
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');
      }
    };
  });
};
```

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.

```ts
// 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>}
  </>
)}
```

![add user form](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3qbmknr7tq4ah4abo012.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h2uo2ce2m06wbk4s9xea.png)

##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.

```ts
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);
      };
    };
  });
};
```

And in our jsx component

```tsx
// 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
)
```

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

![users table](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2z5bjru7xq3vksxo9v27.png)


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

```ts
  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');
      }
    }
  };
```

## 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

```ts
// db.ts/initDb
db.createObjectStore(Stores.Users, { keyPath: 'id' });
```

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

![identify your key path](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a2athoebx8jfu8gwwjya.png)

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.

```ts
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);
      }
    };
  });
};
```

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

```tsx
  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>
    )}
  );
}
```

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](https://github.com/esponges/idxdb). 

##Sources

[MDN](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB)

Photo from <a href="https://unsplash.com/@catgirlmutant?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Catgirlmutant</a> on <a href="https://unsplash.com/es/fotos/EPglXZVqqXI?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>
  

**Extra**

[An interesting approach with promise chaining](https://web.dev/indexeddb/)