Servicios como capa de datos (Data Layer)
En Ionic (con Angular), un servicio es el lugar ideal para centralizar el acceso a datos y el estado compartido: evita duplicar lógica en páginas, facilita pruebas y permite aplicar caching y persistencia sin “ensuciar” la UI. La idea es que las páginas consuman métodos del servicio (CRUD, sincronización, lectura de preferencias) y se suscriban a un flujo de datos reactivo.
Inyección de dependencias (DI) aplicada a servicios
Angular crea e inyecta instancias de servicios automáticamente. En la mayoría de casos, conviene declarar el servicio como singleton de aplicación:
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ItemsService {
// ...
}Luego, cualquier página/componente lo consume por constructor:
constructor(private itemsService: ItemsService) {}Esto garantiza una única fuente de verdad para el estado (mientras la app esté viva) y un punto único para persistencia/caching.
Patrones para compartir datos entre páginas
1) Estado reactivo con BehaviorSubject
Un patrón muy práctico es mantener el estado en memoria con un BehaviorSubject y exponerlo como Observable. Así, cualquier página que se suscriba recibe cambios en tiempo real (alta/edición/borrado) sin necesidad de “pasar datos” manualmente entre rutas.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface Item {
id: string;
title: string;
notes?: string;
updatedAt: number;
}
@Injectable({ providedIn: 'root' })
export class ItemsService {
private readonly _items$ = new BehaviorSubject<Item[]>([]);
readonly items$ = this._items$.asObservable();
get snapshot(): Item[] {
return this._items$.value;
}
setItems(items: Item[]) {
this._items$.next(items);
}
}2) “Store” simple (sin librerías externas)
Si tu app no necesita una solución completa tipo NgRx, un “store” simple dentro del servicio suele ser suficiente: estado en memoria + persistencia local + métodos puros para modificar el estado.
3) Cache simple con TTL (time-to-live)
Para evitar recalcular o re-leer datos con frecuencia, puedes aplicar un cache en memoria con caducidad. Esto es útil incluso si el origen es local (IndexedDB) para evitar lecturas repetidas.
type CacheEntry<T> = { value: T; expiresAt: number };
class SimpleCache {
private map = new Map<string, CacheEntry<any>>();
get<T>(key: string): T | null {
const entry = this.map.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.map.delete(key);
return null;
}
return entry.value as T;
}
set<T>(key: string, value: T, ttlMs: number) {
this.map.set(key, { value, expiresAt: Date.now() + ttlMs });
}
invalidate(key: string) {
this.map.delete(key);
}
clear() {
this.map.clear();
}
}En un servicio real, puedes cachear por ejemplo el resultado de loadAll() durante 5–30 segundos si hay pantallas que entran/salen frecuentemente.
Almacenamiento local: preferencias y datos offline
Para persistencia local en Ionic, una opción común es @ionic/storage-angular, que ofrece una API simple y usa el mejor motor disponible (IndexedDB en web, SQLite si está configurado en nativo, según tu stack). La idea es guardar: (1) preferencias (tema, idioma, flags), (2) datos offline (listas, borradores, caché de respuestas).
Instalación y configuración de Storage
npm install @ionic/storage-angularEn el módulo principal (o en un módulo core), inicializa el storage:
import { IonicStorageModule } from '@ionic/storage-angular';
@NgModule({
imports: [
IonicStorageModule.forRoot()
]
})
export class AppModule {}Servicio de persistencia (wrapper) y serialización
Aunque Storage permite guardar objetos, es recomendable controlar la serialización y la versión de esquema para migraciones. Un wrapper te permite centralizar: claves, versionado, migraciones e invalidación.
import { Injectable } from '@angular/core';
import { Storage } from '@ionic/storage-angular';
interface PersistedEnvelope<T> {
schemaVersion: number;
savedAt: number;
data: T;
}
@Injectable({ providedIn: 'root' })
export class LocalDbService {
private _ready = false;
private readonly SCHEMA_VERSION = 1;
constructor(private storage: Storage) {}
async ready() {
if (this._ready) return;
await this.storage.create();
await this.runMigrationsIfNeeded();
this._ready = true;
}
async set<T>(key: string, data: T): Promise<void> {
await this.ready();
const envelope: PersistedEnvelope<T> = {
schemaVersion: this.SCHEMA_VERSION,
savedAt: Date.now(),
data
};
await this.storage.set(key, envelope);
}
async get<T>(key: string): Promise<T | null> {
await this.ready();
const envelope = await this.storage.get(key) as PersistedEnvelope<T> | null;
if (!envelope) return null;
// Validación mínima
if (typeof envelope.schemaVersion !== 'number' || !('data' in envelope)) return null;
return envelope.data;
}
async remove(key: string) {
await this.ready();
await this.storage.remove(key);
}
private async runMigrationsIfNeeded() {
// Ejemplo: guardar versión global del storage
const versionKey = '__schema_version__';
const current = (await this.storage.get(versionKey)) as number | null;
const from = current ?? 0;
if (from === this.SCHEMA_VERSION) return;
// Migraciones simples por pasos
if (from < 1) {
// v1: primera versión, no hay nada que migrar
}
await this.storage.set(versionKey, this.SCHEMA_VERSION);
}
}Este enfoque evita “romper” datos al cambiar estructuras. Si en el futuro cambias Item (por ejemplo, renombrar campos), puedes agregar un bloque if (from < 2) que lea la clave antigua, transforme y re-guarde.
Estrategias de invalidación
Cuando guardas datos offline o caché, necesitas decidir cuándo se consideran obsoletos:
- Por TTL: guardar
savedAty descartar si supera un tiempo (ej. 24h). - Por evento: invalidar al cerrar sesión, cambiar de cuenta, cambiar de entorno (dev/prod).
- Por versión: invalidar al subir
SCHEMA_VERSIONo al detectar datos incompatibles.
Para TTL, puedes extender el wrapper:
async getWithTtl<T>(key: string, ttlMs: number): Promise<T | null> {
await this.ready();
const envelope = await this.storage.get(key) as any;
if (!envelope) return null;
if (Date.now() - envelope.savedAt > ttlMs) return null;
return envelope.data as T;
}Caso práctico completo: CRUD de una lista con persistencia local
Construiremos una lista de elementos con alta, edición y borrado, manteniendo el estado en un servicio y persistiendo en Storage. El flujo será: (1) cargar desde local al iniciar, (2) exponer items$ para la UI, (3) cada operación CRUD actualiza estado y guarda en local.
1) Definir el modelo y claves de almacenamiento
export interface Item {
id: string;
title: string;
notes?: string;
updatedAt: number;
}
const ITEMS_KEY = 'items_v1';2) Implementar el servicio ItemsService (estado + persistencia)
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { LocalDbService } from './local-db.service';
export interface Item {
id: string;
title: string;
notes?: string;
updatedAt: number;
}
const ITEMS_KEY = 'items_v1';
function uid(): string {
return Math.random().toString(16).slice(2) + Date.now().toString(16);
}
@Injectable({ providedIn: 'root' })
export class ItemsService {
private readonly _items$ = new BehaviorSubject<Item[]>([]);
readonly items$ = this._items$.asObservable();
private hydrated = false;
constructor(private db: LocalDbService) {}
async hydrate() {
if (this.hydrated) return;
const items = (await this.db.get<Item[]>(ITEMS_KEY)) ?? [];
this._items$.next(items);
this.hydrated = true;
}
private async persist(items: Item[]) {
this._items$.next(items);
await this.db.set(ITEMS_KEY, items);
}
async add(title: string, notes?: string) {
await this.hydrate();
const now = Date.now();
const newItem: Item = { id: uid(), title: title.trim(), notes, updatedAt: now };
const items = [newItem, ...this._items$.value];
await this.persist(items);
}
async update(id: string, patch: Partial<Pick<Item, 'title' | 'notes'>>) {
await this.hydrate();
const now = Date.now();
const items = this._items$.value.map(i => {
if (i.id !== id) return i;
return {
...i,
...patch,
title: patch.title !== undefined ? patch.title.trim() : i.title,
updatedAt: now
};
});
await this.persist(items);
}
async remove(id: string) {
await this.hydrate();
const items = this._items$.value.filter(i => i.id !== id);
await this.persist(items);
}
async clearAll() {
await this.hydrate();
await this.persist([]);
}
getById(id: string): Item | undefined {
return this._items$.value.find(i => i.id === id);
}
}Notas importantes:
hydrate()carga una sola vez desde local (evita lecturas repetidas).persist()asegura que UI y almacenamiento se actualicen juntos.- Se usa
updatedAtpara futuras estrategias (orden, sincronización, resolución de conflictos).
3) Página de lista: mostrar, borrar y navegar a edición
Ejemplo de una página items.page.ts que consume el flujo items$. La UI se actualiza sola cuando cambian los datos.
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { ItemsService, Item } from '../services/items.service';
import { AlertController } from '@ionic/angular';
@Component({
selector: 'app-items',
templateUrl: './items.page.html'
})
export class ItemsPage implements OnInit {
items$!: Observable<Item[]>;
constructor(
private itemsService: ItemsService,
private alertCtrl: AlertController
) {}
async ngOnInit() {
await this.itemsService.hydrate();
this.items$ = this.itemsService.items$;
}
async addQuick() {
const alert = await this.alertCtrl.create({
header: 'Nuevo elemento',
inputs: [
{ name: 'title', type: 'text', placeholder: 'Título' },
{ name: 'notes', type: 'textarea', placeholder: 'Notas (opcional)' }
],
buttons: [
{ text: 'Cancelar', role: 'cancel' },
{
text: 'Guardar',
handler: async (data) => {
if (!data.title || !data.title.trim()) return false;
await this.itemsService.add(data.title, data.notes);
return true;
}
}
]
});
await alert.present();
}
async confirmDelete(item: Item) {
const alert = await this.alertCtrl.create({
header: 'Eliminar',
message: `¿Eliminar "${item.title}"?`,
buttons: [
{ text: 'Cancelar', role: 'cancel' },
{ text: 'Eliminar', role: 'destructive', handler: () => this.itemsService.remove(item.id) }
]
});
await alert.present();
}
}Plantilla items.page.html (ejemplo):
<ion-header>
<ion-toolbar>
<ion-title>Items</ion-title>
<ion-buttons slot="end">
<ion-button (click)="addQuick()">Añadir</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>
<ion-item-sliding *ngFor="let item of (items$ | async)">
<ion-item [routerLink]="['/item-edit', item.id]">
<ion-label>
<h2>{{ item.title }}</h2>
<p *ngIf="item.notes">{{ item.notes }}</p>
</ion-label>
</ion-item>
<ion-item-options side="end">
<ion-item-option color="danger" (click)="confirmDelete(item)">Borrar</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</ion-list>
</ion-content>4) Página de edición: cargar por id y guardar cambios
La edición puede resolverse con una ruta tipo /item-edit/:id. La página lee el item desde el servicio (estado en memoria) y, si hace falta, asegura hydrate() antes.
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ItemsService, Item } from '../services/items.service';
@Component({
selector: 'app-item-edit',
templateUrl: './item-edit.page.html'
})
export class ItemEditPage implements OnInit {
item?: Item;
title = '';
notes = '';
constructor(
private route: ActivatedRoute,
private router: Router,
private itemsService: ItemsService
) {}
async ngOnInit() {
await this.itemsService.hydrate();
const id = this.route.snapshot.paramMap.get('id')!;
this.item = this.itemsService.getById(id);
if (!this.item) {
this.router.navigateByUrl('/items');
return;
}
this.title = this.item.title;
this.notes = this.item.notes ?? '';
}
async save() {
if (!this.item) return;
if (!this.title.trim()) return;
await this.itemsService.update(this.item.id, { title: this.title, notes: this.notes });
this.router.navigateByUrl('/items');
}
}Plantilla item-edit.page.html (ejemplo):
<ion-header>
<ion-toolbar>
<ion-title>Editar</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-item>
<ion-input label="Título" [(ngModel)]="title"></ion-input>
</ion-item>
<ion-item>
<ion-textarea label="Notas" [(ngModel)]="notes"></ion-textarea>
</ion-item>
<ion-button expand="block" (click)="save()">Guardar</ion-button>
</ion-content>5) Preferencias locales (ejemplo: modo oscuro)
Además de datos de negocio, es común persistir preferencias. Puedes crear un servicio PreferencesService que use el mismo LocalDbService:
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { LocalDbService } from './local-db.service';
type Theme = 'light' | 'dark';
const PREFS_KEY = 'prefs_v1';
interface Prefs {
theme: Theme;
}
@Injectable({ providedIn: 'root' })
export class PreferencesService {
private readonly _prefs$ = new BehaviorSubject<Prefs>({ theme: 'light' });
readonly prefs$ = this._prefs$.asObservable();
constructor(private db: LocalDbService) {}
async hydrate() {
const prefs = await this.db.get<Prefs>(PREFS_KEY);
if (prefs) this._prefs$.next(prefs);
}
async setTheme(theme: Theme) {
const next = { ...this._prefs$.value, theme };
this._prefs$.next(next);
await this.db.set(PREFS_KEY, next);
}
}Este patrón (estado reactivo + persistencia) se repite: es simple, escalable y mantiene las páginas enfocadas en UI.
Checklist de diseño para tu capa de datos
| Necesidad | Solución recomendada | Dónde vive |
|---|---|---|
| Compartir datos entre páginas | BehaviorSubject + métodos CRUD | Servicio singleton |
| Persistencia offline | @ionic/storage-angular (IndexedDB/SQLite según stack) | Servicio de persistencia |
| Migraciones | Versión global + transformaciones por pasos | Wrapper de storage |
| Invalidación | TTL / eventos / versión | Wrapper + servicios |
| Evitar lecturas repetidas | hydrate() + cache en memoria | Servicio de datos |