Chapter 1: Offline Mode
Offline mode lets the application work without an internet connection.
Strategy
- Store locally using IndexedDB (useDb)
- Synchronize with the server when the connection comes back
- Handle conflicts when the data changed on both sides
useDb configuration
import { useDb } from '@cap-rel/smartcommon';
const db = useDb({
name: 'monApp',
version: 1,
stores: {
// Business data
tasks: 'id, ref, label, status, synced, updatedAt',
// Pending changes queue
syncQueue: 'id++, action, entity, entityId, data, createdAt'
}
});
Custom hook for offline
// hooks/useOfflineSync.js
import { useState, useEffect } from 'react';
import { useApi, useDb } from '@cap-rel/smartcommon';
export function useOfflineSync() {
const api = useApi();
const db = useDb({
name: 'monApp',
version: 1,
stores: {
tasks: 'id, synced',
syncQueue: 'id++, action, entity, entityId, data'
}
});
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [isSyncing, setIsSyncing] = useState(false);
// Detect network status
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
sync(); // Synchronize automatically
};
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Synchronize the data
const sync = async () => {
if (!navigator.onLine || isSyncing) return;
setIsSyncing(true);
try {
// 1. Send the local changes
const queue = await db.syncQueue.toArray();
for (const item of queue) {
try {
if (item.action === 'create') {
await api.private.post(item.entity, { json: item.data });
} else if (item.action === 'update') {
await api.private.put(`${item.entity}/${item.entityId}`, { json: item.data });
} else if (item.action === 'delete') {
await api.del(`${item.entity}/${item.entityId}`);
}
// Remove from the queue
await db.syncQueue.delete(item.id);
} catch (err) {
console.error('Sync error:', err);
}
}
// 2. Fetch fresh data from the server
const data = await api.private.get('tasks').json();
// 3. Update the local database
await db.tasks.clear();
await db.tasks.bulkAdd(data.tasks.map(t => ({ ...t, synced: true })));
} finally {
setIsSyncing(false);
}
};
// Add an action to the queue
const queueAction = async (action, entity, entityId, data) => {
await db.syncQueue.add({
action,
entity,
entityId,
data,
createdAt: Date.now()
});
};
return {
db,
isOnline,
isSyncing,
sync,
queueAction
};
}
Usage in a component
import { useEffect } from 'react';
import { Page, Block, List, ListItem, Spinner, Tag } from '@cap-rel/smartcommon';
import { useApi, useStates } from '@cap-rel/smartcommon';
import { useOfflineSync } from '../../hooks/useOfflineSync';
export const TasksPage = () => {
const api = useApi();
const { db, isOnline, isSyncing, sync, queueAction } = useOfflineSync();
const st = useStates({
initialStates: {
tasks: [],
loading: true
}
});
useEffect(() => {
loadTasks();
}, []);
const loadTasks = async () => {
st.set('loading', true);
try {
if (isOnline) {
// Online: load from the API
const data = await api.private.get('tasks').json();
// Save locally
await db.tasks.clear();
await db.tasks.bulkAdd(data.tasks.map(t => ({ ...t, synced: true })));
st.set('tasks', data.tasks);
} else {
// Offline: load from IndexedDB
const localTasks = await db.tasks.toArray();
st.set('tasks', localTasks);
}
} catch (err) {
// Network error: fall back on the local data
const localTasks = await db.tasks.toArray();
st.set('tasks', localTasks);
} finally {
st.set('loading', false);
}
};
const createTask = async (data) => {
// Create locally with a temporary ID
const tempId = 'temp_' + Date.now();
const newTask = { ...data, id: tempId, synced: false };
await db.tasks.add(newTask);
st.set('tasks', [...st.get('tasks'), newTask]);
if (isOnline) {
try {
const result = await api.private.post('tasks', { json: data }).json();
// Replace the temporary ID with the real one
await db.tasks.update(tempId, { id: result.id, synced: true });
} catch (err) {
// Add to the queue for a later sync
await queueAction('create', 'tasks', tempId, data);
}
} else {
await queueAction('create', 'tasks', tempId, data);
}
};
return (
<Page title="Tasks">
{/* Status indicator */}
<Block>
<div className="flex items-center gap-2">
<Tag color={isOnline ? 'green' : 'red'}>
{isOnline ? 'Online' : 'Offline'}
</Tag>
{isSyncing && <Spinner size="sm" />}
</div>
</Block>
{/* List */}
<Block>
<List>
{st.get('tasks').map(task => (
<ListItem
key={task.id}
title={task.label}
subtitle={!task.synced ? '⏳ Waiting for sync' : null}
/>
))}
</List>
</Block>
</Page>
);
};
Conflict handling
const resolveConflict = async (localData, serverData) => {
// Simple strategy: the most recent one wins
if (localData.updatedAt > serverData.updatedAt) {
// Send the local data to the server
await api.private.put(`tasks/${localData.id}`, { json: localData });
} else {
// Replace locally with the server data
await db.tasks.put(serverData);
}
};
Key takeaways
- useDb for IndexedDB storage
- Detect the network status with navigator.onLine
- Synchronization queue for offline actions
- Synchronize when the connection comes back
- Handle conflicts according to a defined strategy