Chapter 4: Offline Synchronization

SmartCommon provides a complete module for offline-first synchronization of Dolibarr PWA applications.

Overview

The sync module allows:

  • Work offline with local data
  • Automatically synchronize when connection returns
  • Manage data conflicts between client and server

useSyncClient

Main hook for offline-first synchronization.

Import

import { useSyncClient } from '@cap-rel/smartcommon';

Configuration

function MyApp() {
    const {
        isOnline,
        isSyncing,
        pendingCount,
        sync,
        create,
        update,
        remove,
        getConflicts,
        resolveConflict
    } = useSyncClient({
        apiUrl: '/api/smartauth',
        getAccessToken: () => localStorage.getItem('access_token'),
        scope: ['thirdparty', 'contact', 'product']
    });

    return (
        <div>
            <p>Status: {isOnline ? 'Online' : 'Offline'}</p>
            <p>Pending changes: {pendingCount}</p>
        </div>
    );
}

Parameters

Parameter Type Description
apiUrl string Base URL of the synchronization API
getAccessToken function Function returning the JWT token
scope string[] List of entities to synchronize

Returned Values

Property Type Description
isOnline boolean Connection status
isSyncing boolean Synchronization in progress
pendingCount number Number of pending changes
sync function Trigger synchronization
create function Create an entity (offline-capable)
update function Modify an entity
remove function Delete an entity
upsert function Create or update locally (cache)
getConflicts function Get conflicts
resolveConflict function Resolve a conflict

Create Entity

function CreateThirdpartyForm() {
    const { create, pendingCount } = useSyncClient({
        apiUrl: '/api/smartauth',
        getAccessToken: () => localStorage.getItem('access_token'),
        scope: ['thirdparty']
    });

    const handleCreate = async (data) => {
        // Create locally with a temporary ID
        // Will be synchronized when connection returns
        const tempId = await create('thirdparty', {
            name: data.name,
            email: data.email,
            phone: data.phone
        });

        console.log('Created with temporary ID:', tempId);
    };

    return (
        <form onSubmit={handleSubmit}>
            {/* ... */}
            <p>Pending sync: {pendingCount}</p>
        </form>
    );
}

Update and Delete

function ThirdpartyActions({ thirdparty }) {
    const { update, remove } = useSyncClient({
        apiUrl: '/api/smartauth',
        getAccessToken: () => localStorage.getItem('access_token'),
        scope: ['thirdparty']
    });

    const handleUpdate = async () => {
        await update('thirdparty', thirdparty.id, {
            name: 'New name'
        });
    };

    const handleDelete = async () => {
        await remove('thirdparty', thirdparty.id);
    };

    return (
        <div>
            <button onClick={handleUpdate}>Update</button>
            <button onClick={handleDelete}>Delete</button>
        </div>
    );
}

Upsert (Local Cache)

The upsert method allows storing data locally without triggering synchronization to the server. It creates the entity if it doesn't exist, or updates it if it already exists.

function ThirdpartyDetail({ id }) {
    const { upsert, getEntity } = useSyncClient({
        apiUrl: '/api/smartauth',
        getAccessToken: () => localStorage.getItem('access_token'),
        scope: ['thirdparty']
    });

    const cacheServerData = async () => {
        // Get server data
        const data = await api.private.get(`thirdparties/${id}`).json();

        // Store locally without triggering sync
        await upsert('thirdparty', id, data);
    };

    // With queueChange = true, the modification will be synchronized
    const upsertAndSync = async (data) => {
        await upsert('thirdparty', id, data, true);
    };

    // ...
}

Parameters

Parameter Type Default Description
table string - Table name
id number/string - Entity ID
data object - Entity data
queueChange boolean false If true, adds the change to the sync queue

Manual Synchronization

function SyncButton() {
    const { sync, isSyncing, pendingCount, isOnline } = useSyncClient({
        apiUrl: '/api/smartauth',
        getAccessToken: () => localStorage.getItem('access_token'),
        scope: ['thirdparty', 'contact']
    });

    const handleSync = async () => {
        const result = await sync();
        console.log('Synchronized:', result);
    };

    return (
        <button
            onClick={handleSync}
            disabled={isSyncing || !isOnline || pendingCount === 0}
        >
            {isSyncing ? 'Synchronizing...' : `Sync (${pendingCount})`}
        </button>
    );
}

ConflictResolver

UI component to resolve synchronization conflicts.

Import

import { ConflictResolver } from '@cap-rel/smartcommon';

Usage

function SyncManager() {
    const {
        getConflicts,
        resolveConflict
    } = useSyncClient({
        apiUrl: '/api/smartauth',
        getAccessToken: () => localStorage.getItem('access_token'),
        scope: ['thirdparty']
    });

    const [conflicts, setConflicts] = useState([]);

    useEffect(() => {
        loadConflicts();
    }, []);

    const loadConflicts = async () => {
        const list = await getConflicts();
        setConflicts(list);
    };

    const handleResolve = async (conflictId, resolution) => {
        await resolveConflict(conflictId, resolution);
        await loadConflicts();
    };

    if (conflicts.length === 0) {
        return <p>No conflicts</p>;
    }

    return (
        <ConflictResolver
            conflicts={conflicts}
            onResolve={handleResolve}
        />
    );
}

Props

Prop Type Description
conflicts array List of conflicts to display
onResolve function Callback called on resolution

Conflict Structure

{
    id: 'conflict_123',
    entity: 'thirdparty',
    entityId: 456,
    localData: { name: 'Local version', ... },
    serverData: { name: 'Server version', ... },
    localTimestamp: 1707900000000,
    serverTimestamp: 1707899000000
}

Possible Resolutions

  • 'local' : Keep the local version
  • 'server' : Keep the server version
  • 'merge' : Merge (if supported)

useOnlineStatus

Hook to detect online/offline status with optional server health check.

Import

import { useOnlineStatus } from '@cap-rel/smartcommon';

Simple Usage

function NetworkStatus() {
    const { isOnline, isOffline } = useOnlineStatus();

    return (
        <div className={isOffline ? 'bg-red-500' : 'bg-green-500'}>
            {isOnline ? 'Online' : 'Offline'}
        </div>
    );
}

With Server Health Check

function ServerStatus() {
    const {
        isOnline,
        isServerReachable,
        lastCheck,
        checkNow
    } = useOnlineStatus({
        healthCheckUrl: '/api/health',
        healthCheckInterval: 60000,  // Check every 60s
        stabilityDelay: 2000,        // Wait 2s before declaring "online"
        timeout: 5000                // 5s timeout
    });

    return (
        <div>
            <p>Browser: {isOnline ? 'Online' : 'Offline'}</p>
            <p>Server: {isServerReachable ? 'Reachable' : 'Unreachable'}</p>
            <p>Last check: {new Date(lastCheck).toLocaleTimeString()}</p>
            <button onClick={checkNow}>Check now</button>
        </div>
    );
}

Parameters

Parameter Type Default Description
healthCheckUrl string null URL to check server (null = disabled)
healthCheckInterval number 30000 Interval between checks (ms)
stabilityDelay number 2000 Delay before declaring "online" (ms)
timeout number 5000 Health check timeout (ms)

Returned Values

Property Type Description
isOnline boolean Browser is online
isOffline boolean Browser is offline
isServerReachable boolean/null Server is reachable (null if not tested)
lastOnline number Timestamp of last "online" state
lastCheck number Timestamp of last check
checkNow function Force immediate check

useCachedQuery

Hook for query caching with multiple strategies.

Import

import { useCachedQuery, CACHE_STRATEGIES } from '@cap-rel/smartcommon';

Available Strategies

Strategy Description
NETWORK_FIRST Network first, cache as fallback
CACHE_FIRST Cache first if valid, otherwise network
STALE_WHILE_REVALIDATE Show cache, refresh in background

Example: Cache-first for Dictionaries

function CountrySelect() {
    const { data: countries, isLoading, isFromCache } = useCachedQuery({
        db: db.instance,
        store: 'queryCache',
        key: 'countries',
        fetchFn: () => api.get('dictionaries/countries').json(),
        strategy: CACHE_STRATEGIES.CACHE_FIRST,
        ttl: 86400000  // 24h
    });

    if (isLoading) return <Spinner />;

    return (
        <select>
            {countries.map(c => (
                <option key={c.code} value={c.code}>{c.label}</option>
            ))}
        </select>
    );
}

Example: Stale-while-revalidate for Config

function AppConfig() {
    const {
        data: config,
        isStale,
        refetch,
        invalidate
    } = useCachedQuery({
        db: db.instance,
        store: 'queryCache',
        key: 'app-config',
        fetchFn: () => api.get('config').json(),
        strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
        staleTime: 300000  // 5 min
    });

    return (
        <div>
            {isStale && <p>Updating...</p>}
            <button onClick={invalidate}>Force refresh</button>
        </div>
    );
}

Parameters

Parameter Type Default Description
db object - Dexie instance (db.instance)
store string - IndexedDB store name
key string - Cache key
fetchFn function - Data fetch function
strategy string NETWORK_FIRST Cache strategy
ttl number 3600000 Cache TTL (1h)
staleTime number 60000 Time before data is "stale" (1min)
enabled boolean true Enable/disable fetch

Returned Values

Property Type Description
data any Retrieved/cached data
isLoading boolean Loading in progress
isFromCache boolean Data from cache
isStale boolean Data is stale
error Error Error if any
lastFetch number Timestamp of last fetch
refetch function Retry fetch
invalidate function Clear cache and refetch

useAuthenticatedImage

Hook to load authenticated images with IndexedDB cache.

Import

import { useAuthenticatedImage } from '@cap-rel/smartcommon';

Usage

function UserAvatar({ userId }) {
    const { src, isLoading, isFromCache, error } = useAuthenticatedImage({
        db: db.instance,
        store: 'imageCache',
        url: `/api/users/${userId}/photo`,
        token: accessToken,
        placeholder: '/images/default-avatar.png',
        ttl: 86400000,    // 24h
        staleTime: 3600000 // 1h
    });

    if (isLoading) return <Spinner />;

    return <img src={src} alt="Avatar" />;
}

Parameters

Parameter Type Default Description
db object - Dexie instance
store string 'imageCache' Store name
url string - Image URL
token string - JWT token
ttl number 86400000 TTL (24h)
staleTime number 3600000 Time before stale (1h)
placeholder string null Default image

Returned Values

Property Type Description
src string Image URL (blob or placeholder)
isLoading boolean Loading in progress
isFromCache boolean Image from cache
error Error Error if any

IndexedDB Configuration

To use useCachedQuery and useAuthenticatedImage, configure Dexie stores:

const db = useDb({
    name: 'myApp',
    version: 2,
    stores: {
        // Store for cached queries
        queryCache: 'key',

        // Store for images
        imageCache: 'key',

        // Other stores...
        items: 'id++, name'
    }
});

Complete Example: Offline-first Application

import { useEffect } from 'react';
import {
    useSyncClient,
    useOnlineStatus,
    useCachedQuery,
    useDb,
    Page,
    Block,
    List,
    ListItem,
    Button,
    ConflictResolver
} from '@cap-rel/smartcommon';

function ThirdpartyList() {
    const db = useDb({
        name: 'myApp',
        version: 1,
        stores: {
            queryCache: 'key',
            pendingChanges: 'id++, entity, action'
        }
    });

    const { isOnline } = useOnlineStatus({
        healthCheckUrl: '/api/health'
    });

    const {
        sync,
        isSyncing,
        pendingCount,
        getConflicts
    } = useSyncClient({
        apiUrl: '/api/smartauth',
        getAccessToken: () => localStorage.getItem('access_token'),
        scope: ['thirdparty']
    });

    const {
        data: thirdparties,
        isLoading,
        isFromCache,
        refetch
    } = useCachedQuery({
        db: db.instance,
        store: 'queryCache',
        key: 'thirdparties',
        fetchFn: () => api.get('thirdparties').json(),
        strategy: 'swr'
    });

    // Automatically sync when back online
    useEffect(() => {
        if (isOnline && pendingCount > 0) {
            sync();
        }
    }, [isOnline]);

    return (
        <Page title="Third Parties">
            <Block>
                <div className="flex justify-between items-center">
                    <span>
                        {isOnline ? 'Online' : 'Offline'}
                        {isFromCache && ' (cache)'}
                    </span>
                    {pendingCount > 0 && (
                        <Button
                            onClick={sync}
                            disabled={!isOnline || isSyncing}
                        >
                            Sync ({pendingCount})
                        </Button>
                    )}
                </div>
            </Block>

            <Block>
                <List>
                    {thirdparties?.map(t => (
                        <ListItem key={t.id}>
                            {t.name}
                        </ListItem>
                    ))}
                </List>
            </Block>
        </Page>
    );
}

Key Points to Remember

  1. useSyncClient for offline-capable CRUD operations
  2. useOnlineStatus to detect connectivity
  3. useCachedQuery for smart caching with strategies
  4. useAuthenticatedImage for protected images
  5. ConflictResolver for conflict resolution UI
  6. Configure IndexedDB stores for caching

Previous Chapter | Back to Module