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
errorstate. - Loading State: Provide a
loadingstate to indicate when database operations are in progress.
Expected Behavior:
When the useIndexedDB hook is used in a component:
- Upon the first render, it should attempt to open the specified IndexedDB database.
- If the database version is higher than the existing one, the
onupgradeneededevent should trigger to create or update object stores. - The returned functions (
add,get, etc.) should be callable to perform operations on the specified object store. - 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:1schema:[{ 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:2schema:[{ 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
schemaparameter should allow defining multiple object stores, each with astoreName, an optionalkeyPath, and an optionalautoIncrementflag. It should also allow defining indexes for each object store.
Notes
- The
IDBDatabaseobject is generally not directly accessible from the component. The hook should manage its lifecycle. - Consider using
IDBOpenDBRequestand itsonupgradeneeded,onsuccess, andonerrorevents to manage the database connection. - Transactions are fundamental to IndexedDB operations. Ensure each CRUD operation is wrapped in its own transaction.
- The
schemadefinition 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
getoperation should returnundefinedif the key is not found. - The
updateoperation 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
getAlloperation should return an empty array if the object store is empty.