Home

Smarter Local Storage with IndexedDB & Dexie.js

Storing structured, persistent data in React

2025-6-14

Handling Persistent Data with IndexedDB + Dexie.js

When building web apps, especially those with offline capabilities or large client-side data needs, choosing the right storage mechanism is key. While localStorage is simple and widely used, it's limited in performance, size, and data structure support. Instead you could use IndexedDB, a more powerful browser-native database for structured data. It's made simple to use by using Dexie.js.

Why use IndexedDB over localStorage?

localStorage is:

  • Synchronous (blocking)
  • String-only (must JSON.stringify/parse everything)
  • Limited (~5MB total depending on browser)
  • No querying, indexing, or schema support

IndexedDB is:

  • Asynchronous (non-blocking)
  • Supports large datasets (up to hundreds of MB)
  • Can store rich structured objects, Blobs, etc.
  • Allows complex queries with indexes
  • Great for caching, offline data, or persistent app state

It's best to use IndexedDB/Dexie.js when handling user-generated content like drafts, uploads ,forms, etc. or offline-first data much like in my timeboxing app, Tempo.

Using Dexie.js to use IndexedDB

We will be using the example from the docs. Find them here.

npm install dexie dexie-react-hooks

2. Define Your Dexie Database (db.ts or db.js)

// db.ts
import Dexie from 'dexie';

export interface Friend {
  id?: number;
  name: string;
  age: number;
}

class MyDatabase extends Dexie {
  friends!: Dexie.Table<Friend, number>;

  constructor() {
    super('myDatabase');
    this.version(1).stores({
      friends: '++id, name, age',
    });
  }
}

export const db = new MyDatabase();

3. Add Friend Form Component

import React, { useState } from 'react';
import { db } from './db';

export function AddFriendForm({ defaultAge = 21 }) {
  const [name, setName] = useState('');
  const [age, setAge] = useState(defaultAge);
  const [status, setStatus] = useState('');

  async function addFriend() {
    try {
      const id = await db.friends.add({ name, age });
      setStatus(`Friend ${name} added with id ${id}`);
      setName('');
      setAge(defaultAge);
    } catch (error) {
      setStatus(`Error: ${error}`);
    }
  }

  return (
    <div>
      <p>{status}</p>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Name"
      />
      <input
        type="number"
        value={age}
        onChange={(e) => setAge(Number(e.target.value))}
      />
      <button onClick={addFriend}>Add Friend</button>
    </div>
  );
}

4. List Friends with Live Queries

import React from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from './db';

export function FriendList() {
  const friends = useLiveQuery(() => db.friends.toArray(), []);

  if (!friends) return <p>Loading...</p>;

  return (
    <ul>
      {friends.map((friend) => (
        <li key={friend.id}>
          {friend.name}, {friend.age}
        </li>
      ))}
    </ul>
  );
}

5. Dynamic Filtering with IndexedDB Queries

export function FriendList({ minAge, maxAge }) {
  const friends = useLiveQuery(
    () =>
      db.friends
        .where('age')
        .between(minAge, maxAge)
        .toArray(),
    [minAge, maxAge]
  );

  if (!friends) return <p>Loading...</p>;

  return (
    <ul>
      {friends.map((friend) => (
        <li key={friend.id}>
          {friend.name}, {friend.age}
        </li>
      ))}
    </ul>
  );
}

6. App Entry Point

export function App() {
  return (
    <div>
      <h1>Dexie + React</h1>
      <AddFriendForm defaultAge={25} />
      <FriendList minAge={18} maxAge={65} />
    </div>
  );
}

Dexie.js makes working with IndexedDB almost as easy as localStorage, but with superpowers. If your app needs to store more than just tiny key-value pairs — and especially if you're building an offline-first experience — Dexie and IndexedDB are the way to go.