Skip to content

[app] Migrate project storage from localStorage to IndexedDB #166

@NMinhNguyen

Description

@NMinhNguyen

Migrate project storage from localStorage to IndexedDB

Summary

Migrate the project storage mechanism from localStorage to IndexedDB using Dexie.js with the new useSuspendingLiveQuery hook for React Suspense integration.

Background

Currently, projects are stored in localStorage via the useLocalStorageState hook (app/src/app/utils/useLocalStorageState.ts). While functional, localStorage has limitations:

  • 5MB storage limit per origin
  • Synchronous API blocks the main thread
  • String-only storage requires JSON serialization/deserialization
  • No indexing for efficient queries

IndexedDB provides:

  • Much larger storage (typically 50%+ of available disk space)
  • Asynchronous API for better performance
  • Native object storage without serialization overhead
  • Indexed queries for efficient lookups
  • Transaction support for data integrity

Library Choice: Dexie.js with useSuspendingLiveQuery

We'll use Dexie.js with the new useSuspendingLiveQuery hook for native React Suspense support.

Why this approach:

  • Dexie.js - Popular IndexedDB wrapper (11k+ GitHub stars) with excellent TypeScript support
  • useSuspendingLiveQuery - Official Suspense-enabled hook (merged Nov 2025)
  • Reactive updates - Automatically re-renders when data changes
  • No custom hooks needed - Uses official Dexie implementation

Implementation Plan

Phase 1: Install Dependencies

pnpm add dexie [email protected]

Note: useSuspendingLiveQuery is available in the alpha release. Pin to this version until stable release.

Phase 2: Create Database Schema

2.1 Create database definition

  • File: app/src/app/db/database.ts
import Dexie, { type EntityTable } from 'dexie';
import type { Project } from '../actions/project';

const db = new Dexie('RedditHarbor') as Dexie & {
  projects: EntityTable<Project, 'id'>;
};

db.version(1).stores({
  projects: 'id, createdAt', // 'id' is primary key, 'createdAt' is indexed
});

export { db };

Phase 3: Update useProjects Hook

3.1 Rewrite useProjects.ts with useSuspendingLiveQuery

  • File: app/src/app/dashboard/useProjects.ts
import { useSuspendingLiveQuery } from 'dexie-react-hooks';
import { db } from '../db/database';
import type { Project } from '../actions/project';

export function useProjects() {
  const projects = useSuspendingLiveQuery(
    'projects-list',
    () => db.projects.toArray(),
    []
  );

  async function addProject(project: Project) {
    await db.projects.add(project);
  }

  async function updateProject(project: Project) {
    await db.projects.put(project);
  }

  async function deleteProject(project: Project) {
    await db.projects.delete(project.id);
  }

  return [projects, { addProject, updateProject, deleteProject }] as const;
}

Phase 4: Add Suspense Boundaries and Error Handling

4.1 Create error fallback component

  • File: app/src/app/dashboard/ProjectsErrorFallback.tsx
'use client';

import { type FallbackProps } from 'react-error-boundary';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';

export function ProjectsErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <Alert
      severity="error"
      action={
        <Button color="inherit" size="small" onClick={resetErrorBoundary}>
          Retry
        </Button>
      }
    >
      {Error.isError(error) ? `${error}` : 'Failed to load projects'}
    </Alert>
  );
}

4.2 Update Dashboard page with Suspense + ErrorBoundary

  • File: app/src/app/dashboard/page.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ProjectCards } from './ProjectCards';
import { ProjectsErrorFallback } from './ProjectsErrorFallback';

export default function DashboardPage() {
  return (
    <ErrorBoundary FallbackComponent={ProjectsErrorFallback}>
      <Suspense fallback={null}>
        <ProjectCards />
      </Suspense>
    </ErrorBoundary>
  );
}

Phase 5: Cleanup

  • Remove 'projects' localStorage key usage
  • Remove TODO comment from useProjects.ts
  • Keep useLocalStorageState.ts (still used for API keys)

Files to Create/Modify

File Action Description
app/src/app/db/database.ts Create Dexie database definition
app/src/app/dashboard/useProjects.ts Modify Use useSuspendingLiveQuery
app/src/app/dashboard/page.tsx Modify Add Suspense + ErrorBoundary
app/src/app/dashboard/ProjectsErrorFallback.tsx Create Error fallback UI

Technical Considerations

useSuspendingLiveQuery API

useSuspendingLiveQuery<T>(
  cacheKey: string,           // Globally unique cache key
  querier: () => T | Promise<T>,  // Query function
  deps: unknown[]             // Dependency array (like useEffect)
): T

Important: The cacheKey must be globally unique across your application. If two different queries share the same cache key, they may return incorrect cached data.

Error Handling

  • Wrap Suspense in ErrorBoundary for graceful error handling
  • IndexedDB can fail in private browsing mode
  • Consider showing user-friendly error messages

Alpha Version Note

  • useSuspendingLiveQuery is in [email protected]
  • Monitor for stable release and update when available
  • API may change slightly before stable release

API Changes

// Before (synchronous localStorage)
const [projects, { addProject }] = useProjects();
addProject(project); // sync

// After (Suspense + async operations)
// Component wrapped in <Suspense fallback={...}>
const [projects, { addProject }] = useProjects(); // projects is always defined (Suspense handles loading)
await addProject(project); // async

Out of Scope

  • Migration of existing localStorage data (per user preference)
  • Syncing projects across devices (future enhancement)
  • Offline-first architecture (future enhancement)

Testing Checklist

  • Create, update, and delete projects - verify persistence after refresh
  • Test error state: Open in private/incognito mode to trigger IndexedDB error
  • Verify "Retry" button works in error boundary

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions