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 is not SmartMaker's only synchronization mechanism, and it does not fit every need. Before choosing it, read Offline synchronization, which compares the three available mechanisms.

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,
        upsert,
        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>
    );
}

Registering the client, a mandatory step

Before any synchronization, the client must register with the server to obtain its client_uuid. sync() does not do it on its own: without that call, neither push nor pull succeeds.

function SyncBootstrap({ deviceUuid }) {
    const { isInitialized, isRegistered, register } = useSyncClient({
        apiUrl: '/api/smartauth',
        getAccessToken: () => localStorage.getItem('access_token'),
        scope: ['thirdparty']
    });

    useEffect(() => {
        if (isInitialized && !isRegistered && deviceUuid) {
            register(deviceUuid);
        }
    }, [isInitialized, isRegistered, deviceUuid, register]);

    return null;
}

The deviceUuid comes from the SmartAuth JWT, after device identification.

Parameters

Parameter Type Default Description
apiUrl string - Base URL of the synchronization API
getAccessToken function - Function returning the JWT token
scope string[] - List of entities to synchronize
autoSync boolean true Syncs when coming back online, after 2 s of stability
syncInterval number null Periodic synchronization, in milliseconds
onConflict function null Called with the detected conflicts
onSyncStart function null Synchronization started
onSyncComplete function null Synchronization finished
onSyncError function null Synchronization failed
dbName string smartauth_sync Local IndexedDB database name

Returned Values

Property Type Description
isOnline boolean Browser connection status
isServerReachable boolean/null Server reachable
checkNow function Force a connectivity check
isInitialized boolean Engine and local storage ready
isRegistered boolean Client registered with the server
isSyncing boolean Synchronization in progress
lastSyncTime number Timestamp of the last synchronization
pendingCount number Number of pending changes
conflictsCount number Number of unresolved conflicts
syncError Error Last error encountered
register function Register the client (register(deviceUuid))
sync function Push then pull
push function Push only
pull function Pull only
create function Create an entity (offline-capable)
update function Modify an entity
remove function Delete an entity
upsert function Create or update locally (cache)
getEntity function Read a local entity
queryEntities function Read and filter local entities
getConflicts function Get conflicts
resolveConflict function Resolve a conflict
getStatus function Full engine state
reset function Wipe all local synchronization data

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, data) => {
        await resolveConflict(conflictId, resolution, data);
        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 (conflictId, resolution, data)
onCancel function Closes the resolver
renderField function Custom field rendering (optional)
labels object Interface labels (optional, French by default)

Conflict Structure

{
    conflict_id: 'conflict_123',
    table: 'thirdparty',
    object_id: 456,
    client_data: { name: 'Local version', /* ... */ },
    server_data: { name: 'Server version', /* ... */ },
    client_tms: '2026-02-14 10:00:00',
    server_tms: '2026-02-14 09:45:00',
    field_conflicts: ['name'],
    created_at: '2026-02-14T10:01:00.000Z'
}

Possible Resolutions

  • 'client': keep the local version
  • 'server': keep the server version
  • merge: pass 'client' as the resolution and the merged object as the third argument
await resolveConflict(conflict.conflict_id, 'client', {
    ...conflict.server_data,
    label: conflict.client_data.label
});

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,
        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,
        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, as returned by useDb
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,
        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 {
    useApi,
    useSyncClient,
    useOnlineStatus,
    useCachedQuery,
    useDb,
    CACHE_STRATEGIES,
    Page,
    Block,
    List,
    ListItem,
    Button
} from '@cap-rel/smartcommon';

function ThirdpartyList({ deviceUuid }) {
    const api = useApi();

    // This store only serves useCachedQuery. useSyncClient manages its own
    // IndexedDB database (smartauth_sync).
    const db = useDb({
        name: 'myApp',
        version: 1,
        stores: {
            queryCache: 'key'
        }
    });

    const { isOnline } = useOnlineStatus({
        healthCheckUrl: '/api/smartauth/sync/status'
    });

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

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

    // Client registration, without which no synchronization succeeds
    useEffect(() => {
        if (isInitialized && !isRegistered && deviceUuid) {
            register(deviceUuid);
        }
    }, [isInitialized, isRegistered, deviceUuid, register]);

    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>
    );
}

No effect triggers synchronization when connectivity returns here: autoSync is on by default and takes care of it, after a 2 second stability delay and only if changes are still pending.

Key Points to Remember

  1. useSyncClient for offline-capable CRUD operations
  2. register(deviceUuid) before any synchronization, no exception
  3. useOnlineStatus to detect connectivity
  4. useCachedQuery for smart caching with strategies
  5. useAuthenticatedImage for protected images
  6. ConflictResolver for conflict resolution UI
  7. Configure IndexedDB stores for caching, keeping in mind that useSyncClient manages its own separate database

Previous Chapter | Back to Module