Smarter Local Storage with IndexedDB & Dexie.js
Storing structured, persistent data in React
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.