EmpireUI
Get Pro
← Blog9 min read#indexeddb#dexie#react

IndexedDB in React: Offline Storage With Dexie.js

IndexedDB feels like a punishment. Dexie.js fixes that. Here's how to wire real offline-first storage into your React app without losing your mind.

retro computer monitor showing database storage interface in dark room

Why IndexedDB Exists (And Why You Probably Avoid It)

If you've ever looked at the raw IndexedDB API, you understand the avoidance. It's event-driven in a way that feels like 2009 — you open a database, listen for onsuccess, create a transaction, open an object store, call put, and then listen for *another* onsuccess. There's no promise-based interface baked in. The spec was finalized around 2015 and it shows.

That said, the underlying capability is genuinely powerful. You're getting a full transactional, key-value store that lives in the browser and survives tab refreshes, hard reloads, and even browser restarts. localStorage caps you around 5MB. IndexedDB gives you hundreds of megabytes — the exact limit varies by browser and available disk, but Chrome typically allows up to 60% of disk space. That's not even close to the same league.

In practice, the moment your app needs to work without a network connection — think field technicians, note-taking tools, or any dashboard that users expect to stay functional on a plane — localStorage won't cut it. You need something that can handle structured data, indexes, cursors, and transactions. IndexedDB has all of that. The API just makes you suffer to use it.

Dexie.js wraps it in a clean, promise-based interface that actually feels like modern JavaScript. You write it once, it works across every browser that supports IndexedDB (which is all of them since around 2018), and you stop thinking about the underlying machinery.

Setting Up Dexie.js in a React Project

Install it. One package, no peer dependencies to wrestle with.

npm install dexie dexie-react-hooks

The dexie-react-hooks package is optional but you'll want it. It gives you useLiveQuery — a hook that re-renders your component whenever the underlying IndexedDB data changes. It's the reactive glue between your database and your UI.

Define your database schema in a dedicated file. Don't dump it in a component — you'll import this all over the place, and you want a single source of truth for your schema versions.

// src/lib/db.ts
import Dexie, { type Table } from 'dexie';

export interface Note {
  id?: number;
  title: string;
  body: string;
  updatedAt: number;
  synced: boolean;
}

export class AppDB extends Dexie {
  notes!: Table<Note>;

  constructor() {
    super('AppDatabase');
    this.version(1).stores({
      // '++id' = auto-increment primary key
      // 'synced, updatedAt' = indexed fields
      notes: '++id, synced, updatedAt',
    });
  }
}

export const db = new AppDB();

Worth noting: the schema string only lists fields you want to *index*. You don't have to declare every property — Dexie stores the full object regardless. Only add indexes for fields you plan to query or sort by, because each index adds write overhead.

Reading and Writing Data in React Components

With the database defined, actually using it in a component is straightforward. useLiveQuery from dexie-react-hooks takes a Dexie query and returns the result — and re-runs the query whenever the relevant data changes. It's similar to a useEffect + useState combo, but without the boilerplate or the stale-closure bugs.

import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';

export function NotesList() {
  const notes = useLiveQuery(
    () => db.notes.orderBy('updatedAt').reverse().toArray(),
    [] // deps — empty means run once and reactively update
  );

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

  return (
    <ul>
      {notes.map((note) => (
        <li key={note.id}>
          <strong>{note.title}</strong>
          <span>{note.synced ? '✓ synced' : '⏳ pending'}</span>
        </li>
      ))}
    </ul>
  );
}

Writing is just a regular async call. No special hook required — just call db.notes.add() or db.notes.put() in an event handler. When the data changes, useLiveQuery picks it up automatically.

async function saveNote(title: string, body: string) {
  await db.notes.add({
    title,
    body,
    updatedAt: Date.now(),
    synced: false,
  });
}

Honestly, that's the part that gets people. Writing to IndexedDB through Dexie is *this simple* — one await call and you're done. No Redux dispatch, no context update, no setState. The UI re-renders because useLiveQuery is watching the store.

Schema Migrations: How Dexie Handles Versioning

This is where IndexedDB trips people up most. The raw API requires you to handle schema upgrades inside an onupgradeneeded callback, which is annoying and error-prone. Dexie version chaining is much cleaner — you declare each version and an optional upgrade function, and Dexie handles the transaction.

export class AppDB extends Dexie {
  notes!: Table<Note>;
  tags!: Table<Tag>;

  constructor() {
    super('AppDatabase');

    this.version(1).stores({
      notes: '++id, synced, updatedAt',
    });

    this.version(2)
      .stores({
        notes: '++id, synced, updatedAt, tagId', // added tagId index
        tags: '++id, name',
      })
      .upgrade(async (tx) => {
        // backfill existing notes with a default tagId
        await tx.table('notes').toCollection().modify({ tagId: null });
      });
  }
}

Keep all historical versions in the constructor. Don't remove old version() calls — Dexie needs them to know what upgrade path to run for users who are still on version 1. In production, someone is always three versions behind.

Quick aside: version numbers must always increase. You can't jump from 2 to 5 directly during development without thinking it through — users on version 3 or 4 would hit an upgrade gap. Increment by 1 in development and only jump ahead if you're intentionally skipping upgrade paths.

One more thing — if you're in development and just want to nuke the database and start fresh, open DevTools, go to Application > Storage > IndexedDB, and delete the database manually. Much faster than trying to write a downgrade path.

Building a Sync Queue for Offline-First Apps

The real power comes when you pair local storage with a sync mechanism. The pattern: write to IndexedDB immediately (fast, works offline), mark records as synced: false, then flush unsynced records to your server whenever the network is available. The UI never blocks on the network.

// src/lib/sync.ts
import { db } from './db';

export async function syncPendingNotes() {
  const pending = await db.notes
    .where('synced')
    .equals(0) // IndexedDB stores booleans as 0/1 in some engines
    .toArray();

  if (pending.length === 0) return;

  for (const note of pending) {
    try {
      await fetch('/api/notes', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(note),
      });

      await db.notes.update(note.id!, { synced: true });
    } catch {
      // network failed — leave it as unsynced, try next time
      console.warn(`Note ${note.id} failed to sync`);
    }
  }
}

Wire that up to the online event and run it on app startup too — users often close the app offline and reopen it with connectivity restored.

// In your root component or a top-level hook
useEffect(() => {
  syncPendingNotes();
  window.addEventListener('online', syncPendingNotes);
  return () => window.removeEventListener('online', syncPendingNotes);
}, []);

Look, this isn't a complete conflict resolution strategy — if two devices edit the same record offline, you'll need to think about merging. But for 80% of use cases — forms, drafts, note-taking, cart persistence — this pattern works perfectly and takes about 40 lines of code to implement.

Performance Tips and Common Gotchas

A few things will bite you if you're not watching. First, don't put your entire database in a React context value. If db is re-created on every render, you'll get a fresh IndexedDB connection each time, which is slow and wasteful. Define db once as a module-level singleton (like in the setup example above) and import it wherever you need it.

Second, useLiveQuery re-runs whenever *any* data in the queried tables changes. If you're doing a broad query like db.notes.toArray() and you're writing notes frequently, that's a lot of re-renders. Narrow your queries. Use .where() with indexed fields. Only pull the fields you need with .select() or map after the query.

Third, watch out for serialization. IndexedDB can store most JavaScript values — objects, arrays, Blobs, ArrayBuffers — but it can't store class instances, functions, or circular references. If you're storing complex objects, keep them plain. Worth noting: Date objects serialize fine in Dexie, but if you're debugging raw IndexedDB in DevTools, they'll show as ISO strings.

// Bad — querying the whole table, then filtering in JS
const notes = await db.notes.toArray();
const unsynced = notes.filter(n => !n.synced);

// Good — use an index
const unsynced = await db.notes.where('synced').equals(false).toArray();

If your queries are still slow, check what you've indexed. The difference between a full-table scan and an indexed lookup is massive at 10,000+ records. Use db.notes.count() to sanity-check how much data you're actually working with. And if your app is complex enough that you're pulling in hundreds of megabytes of data, consider whether you should also be looking at service workers and caching strategies to round out the offline experience.

Putting It Together: A Minimal Offline Notes App

Here's a stripped-down but functional example that combines everything above — create, list, and a sync indicator. This is the kind of thing you'd build in an hour once you know the pattern. The UI here is minimal; if you want something that actually looks good, Empire UI has components — glassmorphism components and card patterns that slot right into this kind of data-driven layout.

import { useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';
import { syncPendingNotes } from '@/lib/sync';

export function OfflineNotesApp() {
  const [title, setTitle] = useState('');
  const notes = useLiveQuery(() =>
    db.notes.orderBy('updatedAt').reverse().limit(50).toArray()
  );

  const handleAdd = async () => {
    if (!title.trim()) return;
    await db.notes.add({
      title,
      body: '',
      updatedAt: Date.now(),
      synced: false,
    });
    setTitle('');
  };

  const unsyncedCount = notes?.filter(n => !n.synced).length ?? 0;

  return (
    <div style={{ padding: '24px', maxWidth: '600px' }}>
      {unsyncedCount > 0 && (
        <div style={{ marginBottom: '16px', color: '#f59e0b' }}>
          {unsyncedCount} note(s) pending sync
          <button onClick={syncPendingNotes} style={{ marginLeft: '8px' }}>
            Sync now
          </button>
        </div>
      )}
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Note title"
        style={{ width: '100%', marginBottom: '8px', padding: '8px' }}
      />
      <button onClick={handleAdd}>Add Note</button>
      <ul style={{ marginTop: '16px' }}>
        {notes?.map((note) => (
          <li key={note.id} style={{ padding: '8px 0', borderBottom: '1px solid #333' }}>
            {note.title}
            <span style={{ marginLeft: '8px', fontSize: '12px', opacity: 0.6 }}>
              {note.synced ? 'synced' : 'local only'}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

That's the full loop. Write locally, display reactively, sync when online. The useLiveQuery call with a .limit(50) is important — don't paginate in JavaScript after loading everything, paginate at the query level. Your future self at 5,000 records will thank you.

From here, you can build in deletions, full-text search (Dexie has a plugin for that), or a more sophisticated conflict resolution model. But this foundation is solid. And unlike sessionStorage or a big React context blob, it'll actually survive a page reload.

FAQ

Is Dexie.js production-ready in 2026?

Yes. It's been around since 2014, has over 12 million weekly npm downloads, and is used by companies like Microsoft in production. Version 3.x is stable and actively maintained.

Can I use IndexedDB in a Next.js app with SSR?

IndexedDB is browser-only, so you can't call it server-side. Wrap your Dexie imports in a useEffect or dynamic import with ssr: false to keep them client-side only.

How do I clear all IndexedDB data for a user?

Call await db.delete() followed by db.open() to wipe and recreate the database. For per-table clearing, use await db.notes.clear().

Does Dexie work in Safari?

Yes, Safari has supported IndexedDB since version 10 (2016). There were quirks in older Safari versions, but anything from Safari 14+ works without workarounds.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Three.js with React: Particles, Blobs and Interactive 3D ScenesTauri + React: Build Desktop Apps With Web TechnologiesProgressive Web Apps with React and Next.js in 2026Offline-First React Apps: IndexedDB, SyncManager, PWA