A Modern Approach to IndexedDB in Angular: Reactive Data with Observables and Signals
By combining RxJS Observables for handling data streams and Signals for automatic UI updates upon state changes, we can manage IndexedDB in a much more powerful and declarative way.
This article will guide you through a complete CRUD example of integrating a Promise-based IndexedDB service into Angular’s reactive ecosystem.
The Plan
- Separate Layers: We’ll separate a low-level, framework-agnostic DB service from a high-level Angular service that wraps it with Observables.
- Observable Transformation: Convert the Promise-based methods of the low-level service into RxJS Observables using the from operator.
- State Management with Signals: Within the component, use Signals to manage the data state, ensuring the UI updates automatically whenever the data changes.
- Complete Reactive UI: Render the data from the Signal in the template using the @for syntax and implement full CRUD functionality.
DEMO Implementation
Step 1: The Low-Level DB Service (Promise-based)
First, we’ll write a pure TypeScript DBService that has no Angular dependencies. All CRUD operations return Promises.
// src/db.service.ts (Framework-agnostic)
import { User } from './user.model';
const DB_NAME = 'MyUserDB';
const STORE_NAME = 'users';
const DB_VERSION = 1;
export class DBService {
private db: IDBDatabase | null = null;
public async openDB(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.db) return resolve();
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject('Error opening database');
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
}
};
});
}
private getStore(mode: IDBTransactionMode): IDBObjectStore {
if (!this.db) throw new Error('Database not initialized!');
return this.db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
}
public async getAllUsers(): Promise<User[]> {
const request = this.getStore('readonly').getAll();
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
public async addUser(user: Omit<User, 'id'>): Promise<User> {
const request = this.getStore('readwrite').add(user);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve({ ...user, id: request.result as number });
request.onerror = () => reject(request.error);
});
}
public async updateUser(user: User): Promise<User> {
const request = this.getStore('readwrite').put(user);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(user);
request.onerror = () => reject(request.error);
});
}
public async deleteUser(id: number): Promise<void> {
const request = this.getStore('readwrite').delete(id);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
Step 2: The Angular Service Wrapper (The Observable Bridge)
Now, let’s create an Angular Injectable service that converts the Promises from DBService into Observables.
// src/app/user-db.service.ts (Angular Service)
import { Injectable } from '@angular/core';
import { from, Observable, switchMap, shareReplay } from 'rxjs';
import { DBService } from '../db.service';
import { User } from '../user.model';
@Injectable({ providedIn: 'root' })
export class UserDbService {
private dbService = new DBService();
private dbReady$ = from(this.dbService.openDB()).pipe(shareReplay(1));
getAllUsers(): Observable<User[]> {
return this.dbReady$.pipe(switchMap(() => from(this.dbService.getAllUsers())));
}
addUser(user: Omit<User, 'id'>): Observable<User> {
return this.dbReady$.pipe(switchMap(() => from(this.dbService.addUser(user))));
}
updateUser(user: User): Observable<User> {
return this.dbReady$.pipe(switchMap(() => from(this.dbService.updateUser(user))));
}
deleteUser(id: number): Observable<void> {
return this.dbReady$.pipe(switchMap(() => from(this.dbService.deleteUser(id))));
}
}
Step 3: The Reactive Component (Signals & UI)
In our component, we inject UserDbService to manage data. The state of the data is stored in a Signal to synchronize it with the UI.
// src/app/app.component.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { UserDbService } from './user-db.service';
import { User } from '../user.model';
@Component({
selector: 'app-root',
standalone: true,
imports: [],
templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
private userDbService = inject(UserDbService);
users = signal<User[]>([]);
ngOnInit(): void {
this.loadUsers();
}
loadUsers(): void {
this.userDbService.getAllUsers().subscribe({
next: (users) => this.users.set(users),
error: (err) => console.error('Failed to load users', err),
});
}
addUser(): void {
const name = prompt("Enter user name:");
const email = prompt("Enter user email:");
if (!name || !email) return;
this.userDbService.addUser({ name, email }).subscribe({
next: (addedUser) => {
this.users.update((currentUsers) => [...currentUsers, addedUser]);
},
error: (err) => console.error('Failed to add user', err),
});
}
updateUser(user: User): void {
const newName = prompt("Enter new name:", user.name);
if (!newName) return;
const updatedUser = { ...user, name: newName };
this.userDbService.updateUser(updatedUser).subscribe({
next: (returnedUser) => {
this.users.update((users) =>
users.map((u) => (u.id === returnedUser.id ? returnedUser : u))
);
},
error: (err) => console.error('Failed to update user', err),
});
}
deleteUser(id: number): void {
if (!confirm('Are you sure you want to delete this user?')) return;
this.userDbService.deleteUser(id).subscribe({
next: () => {
this.users.update((users) => users.filter((u) => u.id !== id));
},
error: (err) => console.error('Failed to delete user', err),
});
}
}
Step 4: The Template (Control Flow)
We use @for to iterate over the users Signal and render the UI.
<!-- src/app/app.component.html -->
<main>
<h1>Angular IndexedDB with Signals & Observables</h1>
<button (click)="addUser()">Add User</button>
<ul>
@for (user of users(); track user.id) {
<li>
<span>ID: | Name: | Email: </span>
<div>
<button (click)="updateUser(user)">Update Name</button>
<button (click)="deleteUser(user.id)">Delete</button>
</div>
</li>
} @empty {
<p>No users found in IndexedDB. Add one!</p>
}
</ul>
</main>
Conclusion
Integrating IndexedDB with Angular’s reactive paradigm creates a powerful and declarative data management architecture.
- Low-Level Promise Service: Encapsulates the complexity of IndexedDB.
- High-Level Observable Service: Manages asynchronous data flows and connects to the Angular ecosystem.
- Signals in Components: Manage UI state and automatically update the view in response to data changes.
This layered approach enhances code reusability and testability, helping you maintain clean and robust client-side data logic.
댓글남기기