0%
[/]JOSHIDEAS
SYSTEM_BLOG
TIME:13:33:15
STATUS:ONLINE
~/blog/headless-drupal-react-pwa
$cat headless-drupal-react-pwa.md_

Headless Drupal + React PWA: Real-Time Sync Architecture

#Drupal#React#PWA

# Headless Drupal + React PWA: Real-Time Sync Architecture

Building offline-first applications requires a fundamentally different approach. This architecture combines Drupal's content management power with React's UI capabilities and PWA's offline resilience.

## Architecture Overview

┌────────────────────────────────────────────────────────────┐
│                     React PWA Client                       │
├────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐ │
│  │   UI Layer   │  │ Service      │  │   IndexedDB     │ │
│  │   (React)    │  │ Worker       │  │   (Local Store) │ │
│  └──────────────┘  └──────────────┘  └──────────────────┘ │
└─────────────────────────────┬──────────────────────────────┘
                              │ WebSocket / REST
┌─────────────────────────────▼──────────────────────────────┐
│                    Drupal Backend                          │
├────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐ │
│  │   JSON:API   │  │   WebSocket  │  │    Content      │ │
│  │   Endpoints  │  │   Server     │  │    Management   │ │
│  └──────────────┘  └──────────────┘  └──────────────────┘ │
└────────────────────────────────────────────────────────────┘

## IndexedDB Storage Layer

Local storage powers offline functionality:

javascript
class LocalStore { constructor(dbName, version = 1) { this.dbName = dbName; this.version = version; this.db = null; } async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.version); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(this); }; request.onupgradeneeded = (event) => { const db = event.target.result; // Create object stores if (!db.objectStoreNames.contains('entities')) { const store = db.createObjectStore('entities', { keyPath: 'id' }); store.createIndex('type', 'type'); store.createIndex('modified', 'modified'); } if (!db.objectStoreNames.contains('syncQueue')) { db.createObjectStore('syncQueue', { autoIncrement: true }); } }; }); } async put(storeName, data) { const tx = this.db.transaction(storeName, 'readwrite'); const store = tx.objectStore(storeName); return store.put(data); } async getAll(storeName, indexName, query) { const tx = this.db.transaction(storeName, 'readonly'); const store = tx.objectStore(storeName); const index = indexName ? store.index(indexName) : store; return index.getAll(query); } }

## Service Worker Strategy

The service worker handles caching and background sync:

javascript
// sw.js const CACHE_NAME = 'pwa-cache-v1'; const OFFLINE_URL = '/offline.html'; self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { return cache.addAll([ '/', '/offline.html', '/static/js/main.js', '/static/css/main.css' ]); }) ); }); self.addEventListener('fetch', (event) => { // Network-first for API calls if (event.request.url.includes('/api/')) { event.respondWith(networkFirst(event.request)); return; } // Cache-first for static assets event.respondWith(cacheFirst(event.request)); }); async function networkFirst(request) { try { const response = await fetch(request); const cache = await caches.open(CACHE_NAME); cache.put(request, response.clone()); return response; } catch (error) { return caches.match(request); } }

## Timestamp-Based Differential Sync

Only sync what changed since last update:

javascript
class SyncManager { constructor(apiUrl, localStore) { this.apiUrl = apiUrl; this.localStore = localStore; } async sync() { const lastSync = await this.getLastSyncTimestamp(); // Fetch changes from server const response = await fetch( `${this.apiUrl}/sync?since=${lastSync}` ); const { entities, deletions, timestamp } = await response.json(); // Apply updates for (const entity of entities) { await this.localStore.put('entities', entity); } // Handle deletions for (const id of deletions) { await this.localStore.delete('entities', id); } // Push local changes await this.pushLocalChanges(); // Update sync timestamp await this.setLastSyncTimestamp(timestamp); } async pushLocalChanges() { const queue = await this.localStore.getAll('syncQueue'); for (const item of queue) { try { await fetch(`${this.apiUrl}/sync`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item) }); await this.localStore.delete('syncQueue', item.id); } catch (error) { // Will retry on next sync console.error('Sync failed:', error); } } } }

## WebSocket Real-Time Updates

Live updates push immediately to connected clients:

javascript
class RealtimeConnection { constructor(wsUrl, onUpdate) { this.wsUrl = wsUrl; this.onUpdate = onUpdate; this.ws = null; this.reconnectAttempts = 0; } connect() { this.ws = new WebSocket(this.wsUrl); this.ws.onopen = () => { console.log('WebSocket connected'); this.reconnectAttempts = 0; }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); switch (data.type) { case 'ENTITY_UPDATE': this.onUpdate(data.entity); break; case 'ENTITY_DELETE': this.onUpdate({ id: data.id, _deleted: true }); break; } }; this.ws.onclose = () => { this.scheduleReconnect(); }; } scheduleReconnect() { const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); this.reconnectAttempts++; setTimeout(() => this.connect(), delay); } }

## React Integration

Hooks tie everything together:

javascript
function useOfflineData(entityType) { const [data, setData] = useState([]); const [isOnline, setIsOnline] = useState(navigator.onLine); const [isSyncing, setIsSyncing] = useState(false); useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); useEffect(() => { // Load from IndexedDB first localStore.getAll('entities', 'type', entityType) .then(setData); // Then sync if online if (isOnline) { setIsSyncing(true); syncManager.sync() .then(() => localStore.getAll('entities', 'type', entityType)) .then(setData) .finally(() => setIsSyncing(false)); } }, [entityType, isOnline]); return { data, isOnline, isSyncing }; }

## Conflict Resolution

When offline edits conflict with server changes:

StrategyUse Case
Last Write WinsSimple fields, non-critical data
Server WinsAuthoritative data, prices
Client WinsUser preferences
Manual MergeComplex documents

## Conclusion

Offline-first PWAs require careful orchestration of local storage, sync protocols, and real-time updates. This architecture handles the complexity while providing users with seamless experiences regardless of connectivity.

The combination of Drupal's structured content and React's reactive UI creates applications that feel native while leveraging web technologies.