Hone logo
Hone
Problems

React useIndexedDB Hook for Persistent Data Management

This challenge involves creating a custom React hook, useIndexedDB, that simplifies interactions with the browser's IndexedDB API. This hook will abstract away the complexities of opening databases, managing object stores, and performing CRUD operations, making it easier for developers to implement persistent data storage in their React applications.

Problem Description

Your task is to create a TypeScript React hook named useIndexedDB. This hook should provide a convenient interface for interacting with IndexedDB, allowing React components to easily read, write, update, and delete data.

Key Requirements:

  • Database Initialization: The hook should accept parameters for database name, version, and an optional schema definition (object store names, key paths, indexes). It should handle the opening and upgrading of the IndexedDB database.
  • CRUD Operations: The hook should expose functions to perform the following operations on a specified object store:
    • add(data: T): Adds a new record.
    • get(key: IDBKey): Retrieves a record by its key.
    • getAll(): Retrieves all records.
    • update(key: IDBKey, data: Partial<T>): Updates an existing record.
    • delete(key: IDBKey): Deletes a record by its key.
  • State Management: The hook should manage the state of the database connection and any errors encountered during operations.
  • Type Safety: The hook should be strongly typed using TypeScript, allowing for generic type parameters for the data stored in object stores.
  • Error Handling: Implement robust error handling for all IndexedDB operations and expose an error state.
  • Loading State: Provide a loading state to indicate when database operations are in progress.

Expected Behavior:

When the useIndexedDB hook is used in a component:

  1. Upon the first render, it should attempt to open the specified IndexedDB database.
  2. If the database version is higher than the existing one, the onupgradeneeded event should trigger to create or update object stores.
  3. The returned functions (add, get, etc.) should be callable to perform operations on the specified object store.
  4. The hook should return the current state of the data (e.g., from getAll), loading status, and any errors.

Edge Cases to Consider:

  • Database Not Supported: Handle cases where the browser does not support IndexedDB.
  • Failed Connection/Upgrade: Gracefully handle errors during database opening or upgrading.
  • Invalid Object Store Name: Ensure operations don't fail silently if an invalid object store name is provided.
  • Concurrency: While full concurrency management is complex, consider how multiple operations might be queued or handled. For this challenge, a simple queue or serial execution of operations on the same object store is acceptable.
  • Key Generation: Assume that records will have a primary key (either implicitly generated or provided). If a key path is specified, use that; otherwise, rely on auto-increment.

Examples

Example 1: Adding and Retrieving Data

interface User {
  id: number;
  name: string;
  email: string;
}

const { data, add, get, loading, error } = useIndexedDB<User>({
  dbName: 'myAppDB',
  version: 1,
  schema: [
    {
      storeName: 'users',
      keyPath: 'id',
      autoIncrement: true,
    },
  ],
});

// In a component:
useEffect(() => {
  if (!loading && !error) {
    add({ name: 'Alice', email: 'alice@example.com' });
  }
}, [loading, error, add]);

useEffect(() => {
  if (!loading && !error && data) {
    const user = get(1); // Assuming 'id' 1 was added
    console.log(user);
  }
}, [loading, error, data, get]);

Input:

  • dbName: 'myAppDB'
  • version: 1
  • schema: [{ storeName: 'users', keyPath: 'id', autoIncrement: true }]
  • add({ name: 'Alice', email: 'alice@example.com' })
  • get(1)

Output (Conceptual):

After the add operation completes successfully, the data state will eventually reflect the added user. After get(1), the user variable in the component will hold the user object: { id: 1, name: 'Alice', email: 'alice@example.com' }. The loading state will transition from true to false.

Example 2: Updating and Deleting Data

interface Product {
  sku: string;
  name: string;
  price: number;
}

const { data, add, update, delete: deleteRecord, getAll, loading, error } = useIndexedDB<Product>({
  dbName: 'inventoryDB',
  version: 2,
  schema: [
    { storeName: 'products', keyPath: 'sku' },
  ],
});

useEffect(() => {
  if (!loading && !error) {
    add({ sku: 'ABC-123', name: 'Gadget Pro', price: 99.99 });
  }
}, [loading, error, add]);

useEffect(() => {
  if (!loading && !error && data?.some(p => p.sku === 'ABC-123')) {
    update('ABC-123', { price: 119.99 });
    deleteRecord('ABC-123');
    getAll(); // To re-fetch data and see the changes
  }
}, [loading, error, data, update, deleteRecord, getAll]);

Input:

  • dbName: 'inventoryDB'
  • version: 2
  • schema: [{ storeName: 'products', keyPath: 'sku' }]
  • add({ sku: 'ABC-123', name: 'Gadget Pro', price: 99.99 })
  • update('ABC-123', { price: 119.99 })
  • deleteRecord('ABC-123')
  • getAll()

Output (Conceptual):

Initially, the products object store will contain one record. After the update operation, the price for ABC-123 will be 119.99. After the deleteRecord operation, the products object store will be empty (or have other existing records). The data state will be updated accordingly after getAll().

Example 3: Schema Upgrade

Imagine useIndexedDB is called with version: 1 initially, and then later with version: 2 and a new object store.

Input:

  • Initial call: useIndexedDB({ dbName: 'settings', version: 1, schema: [{ storeName: 'preferences', keyPath: 'key' }] })
  • Subsequent call: useIndexedDB({ dbName: 'settings', version: 2, schema: [{ storeName: 'preferences', keyPath: 'key' }, { storeName: 'themes', keyPath: 'id', autoIncrement: true }] })

Output (Conceptual):

When the hook is called with version: 2, the onupgradeneeded event will fire. The IndexedDB API will create the preferences object store (if it doesn't exist) and then create the new themes object store. The version state returned by the hook should reflect the latest version.

Constraints

  • The hook must be implemented in TypeScript.
  • It must be compatible with React 18+.
  • All IndexedDB operations should be asynchronous and return Promises.
  • The hook should not rely on any third-party libraries for IndexedDB interaction (only standard browser APIs).
  • The schema parameter should allow defining multiple object stores, each with a storeName, an optional keyPath, and an optional autoIncrement flag. It should also allow defining indexes for each object store.

Notes

  • The IDBDatabase object is generally not directly accessible from the component. The hook should manage its lifecycle.
  • Consider using IDBOpenDBRequest and its onupgradeneeded, onsuccess, and onerror events to manage the database connection.
  • Transactions are fundamental to IndexedDB operations. Ensure each CRUD operation is wrapped in its own transaction.
  • The schema definition can be extended to include index definitions (e.g., { name: 'emailIndex', keyPath: 'email', unique: true }).
  • Think about how to handle multiple components using the same database and object store. The hook should ideally manage a single connection and provide data to all consumers.
  • The get operation should return undefined if the key is not found.
  • The update operation should only update if the record with the given key exists. If it doesn't exist, it should ideally do nothing or throw a specific error.
  • The getAll operation should return an empty array if the object store is empty.
Loading editor...
typescript