Chapter 1: Offline Mode

Offline mode lets the application work without an internet connection.

Strategy

  1. Store locally using IndexedDB (useDb)
  2. Synchronize with the server when the connection comes back
  3. 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

  1. useDb for IndexedDB storage
  2. Detect the network status with navigator.onLine
  3. Synchronization queue for offline actions
  4. Synchronize when the connection comes back
  5. Handle conflicts according to a defined strategy

Back to Module | Next Chapter: Internationalization ->