Gestión de estado y datos en Ionic con servicios y almacenamiento local

Capítulo 5

Tiempo estimado de lectura: 11 minutos

+ Ejercicio

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.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

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-angular

En 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 savedAt y 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_VERSION o 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 updatedAt para 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

NecesidadSolución recomendadaDónde vive
Compartir datos entre páginasBehaviorSubject + métodos CRUDServicio singleton
Persistencia offline@ionic/storage-angular (IndexedDB/SQLite según stack)Servicio de persistencia
MigracionesVersión global + transformaciones por pasosWrapper de storage
InvalidaciónTTL / eventos / versiónWrapper + servicios
Evitar lecturas repetidashydrate() + cache en memoriaServicio de datos

Ahora responde el ejercicio sobre el contenido:

¿Cuál es la principal ventaja de mantener el estado compartido en un servicio singleton usando BehaviorSubject y exponerlo como Observable?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Un servicio singleton con BehaviorSubject centraliza el estado y expone un flujo reactivo (Observable), permitiendo que distintas páginas se suscriban y se actualicen automáticamente ante cambios (alta/edición/borrado) sin pasar datos manualmente.

Siguiente capítulo

Consumo de APIs en Ionic: HTTP, asincronía y manejo de errores

Arrow Right Icon
Portada de libro electrónico gratuitaIonic desde Cero: Crea Aplicaciones Híbridas con HTML, CSS y TypeScript
50%

Ionic desde Cero: Crea Aplicaciones Híbridas con HTML, CSS y TypeScript

Nuevo curso

10 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.