Offline First
What is the fundamental difference between an App and a Web Site? What is “that aspect” that makes you consider that an App is not well implemented and what we tolerate on a Web?
Obviously there are many differences. Mobile user experience, user interfaces adapted to gestures, access to device capabilities such as camera or GPS.
However, there is a key factor that makes us hate an App: not supporting being offline.
Can you imagine trying to access your previous emails and you can’t because you don’t have an Internet connection? It is frustrating on desktop Web, yes. But it is absolutely unbearable on mobile Web. And totally intolerable in an App / PWA.
Service Workers
A service worker is a code script executed in background by the browser, separated from a web page, offering functionality to the Web that is not related to user interaction.
In other words, a service worker becomes something like a backend-on-browser, which runs code in parallel with the logic of the user interface and that allows to intercept and manage network requests as well as retrieve data from custom managed cache.
It is, in short, a technology that could allow offline user experiences since if there is no Internet connection the application’s requests are intercepted and resolved by a cache that returns (for example) the last available data.
Ok, this could solve intermittent Internet connections. But what about a total loss of continued Internet access?
Offline Work and Sync Data
Creating an offline user experience can be complex since there are many things to worry about as how to synchronize correctly when Internet comes back or how to manage conflicts when multiple users modify the same information at the same time.
Service workers are a great technology that allows partially solving problems through an infinite cache while there is no connection, but does not resolve conflicts or is able to mix information that has been modified in the client while other clients have also modified data and have already published that to Internet.
For this we need an offline operation and synchronization mechanism with conflict management.
PouchDB and 4-Way Data Binding
PouchDB is an in-browser database that allows applications to save data locally, so that users can enjoy all the features of an app even when they’re offline. Plus, the data is synchronized between clients, so users can stay up-to-date wherever they go.
One of the best goal you can achieve using PouchDB is to implement 4-way data binding by keeping the Model, View, Serve & Offline Data all in sync while providing the user with a mature offline experience.
All CRUD operations performed with PouchDB are versioned in a history similar to the history of changes that occurs in a Git code repository. That is, if what you want to do is create a new data (CREATE), a new command (PUT) is versioned and added to the transaction log. If subsequently what is desired is to delete that same data (REMOVE) a new command (REMOVE) is versioned and added again to the transaction log.
That is, all CRUD requests add commands to the PouchDB history.
This allows PouchDB to compute which are the pending changes to be applied when uploading data to the server in a synchronization operation as well as correctly determining which are the changes in the server that must be applied in the local PouchDB database.
Example – To-Do PWA
We are going to create an example PWA to try all of this: a simple To-Do PWA that is automatically synced and also works in offline mode.
The application consists of a simple list of pending tasks that can be:
- Created
- Retrieved
- Updated
- Deleted
Since we are going to need a backend that synchronize all our data when connected, we will use an instance of CouchDB executed with Docker Compose:
version: '3' services: couchdb: image: couchdb ports: - '5984:5984'
To start it, simply launch the following command:
$ docker-compose up
Once it is running it is necessary to enable CORS so that requests made by PouchDB from the PWA that will be executed in the browser can reach the CouchDB server:
Next we will create a PWA with Ionic Framework 4 and Angular 8:
$ ionic start todoapp blank
The item data model will have the following structure:
export interface Item { _id?: string; _rev?: string; title: string; description: string; }
It will be necessary to create a new Angular Service that performs the CRUD operations of the PWA user interface:
import { Injectable } from '@angular/core'; import { Item } from '../models/item'; import PouchDB from 'pouchdb'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class ItemsService { private readonly db = new PouchDB('items'); constructor() { this.db.sync('http://localhost:5984/items', { live: true, retry: true }); } async findAll() { const docs = await this.db.allDocs({ include_docs: true }); return docs.rows.map(row => row.doc); } add(item: Item) { return this.db.post(item); } remove(item: Item) { return this.db.remove(item._id, item._rev); } update(item: Item) { return this.db.put(item); } changes() { return new Observable(subscriber => { this.db .changes({ live: true, since: 'now' }) .on('change', _ => { subscriber.next(); }); }); } }
We will modify the home.page.html page to display the PWA interface:
And the controller of that page will use the previously created service:
import { Component, OnInit, ChangeDetectorRef } from '@angular/core'; import { Item } from '../../models/item'; import { ItemsService } from '../../services/items.service'; import { AlertController } from '@ionic/angular'; @Component({ selector: 'app-home', templateUrl: 'home.page.html', styleUrls: ['home.page.scss'] }) export class HomePage implements OnInit { items: Item[] = []; constructor( private alertCtrl: AlertController, private itemsService: ItemsService, private changeDetetorRef: ChangeDetectorRef ) {} ngOnInit() { this.itemsService.changes().subscribe(() => { this.refresh(); }); this.refresh(); } private refresh() { this.itemsService.findAll().then(docs => { this.items = docs; }); this.changeDetetorRef.markForCheck(); } async add() { const alert = await this.alertCtrl.create({ header: 'New item', inputs: [ { name: 'title', placeholder: 'Title' }, { name: 'description', placeholder: 'Description' }], buttons: [ { text: 'Cancel', role: 'cancel' }, { text: 'Add', handler: (item: Item) => { this.itemsService.add(item); } } ] }); alert.present(); } async edit(item: Item) { const alert = await this.alertCtrl.create({ header: 'Edit item', inputs: [ { name: 'title', placeholder: 'Title', value: item.title }, { name: 'description', placeholder: 'Description', value: item.description }], buttons: [ { text: 'Cancel', role: 'cancel' }, { text: 'Save', handler: (newItem: Item) => { newItem._id = item._id; newItem._rev = item._rev; this.itemsService.update(newItem); } } ] }); alert.present(); } remove(item: Item) { this.itemsService.remove(item); } }
The end result is a synchronized PWA that allows you to continue working even when there is no Internet connection!
GitHub source: https://github.com/okode/offline-todo-app