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({
        scope: ['thirdparty', 'contact', 'product']
    });

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

The hook goes through useApi: mounted under <Provider>, it gets the API URL, the token and the device identifier on its own. No component has to pass it a token.

scope lists SmartAuth object types (thirdparty, contact, product, category, order, invoice...), parents first: creations go out in that order, deletions in reverse order.

Client registration

Registration with the server is automatic: on the first sync(), push() or pull(), the hook generates a client_uuid, keeps it in its database and sends it to sync/register. The device only has to be identified beforehand (<DeviceIdentificationComponent>): the server reads the device from the token.

Two identifiers are involved, not to be confused:

  • device_uuid: provided by SmartAuth on device identification, carried by the JWT;
  • client_uuid: generated client-side, sent in the query (GET) or the body (POST) of every sync/* call.

register() remains available to register before the first synchronization, without argument.

Parameters

Parameter Type Default Description
scope string[] - SmartAuth object types to synchronize, parents first
appVersion string null Version sent to sync/register
healthCheckUrl string/null health route Reachability probe, null for network state only
autoSync boolean true Synchronizes when back online, after 2 s of stability
syncInterval number null Periodic synchronization, in milliseconds
onConflict function null Called with detected conflicts
onSyncStart function null Synchronization start
onSyncComplete function null Synchronization end
onSyncError function null Synchronization failure
dbName string smartauth_sync Local IndexedDB database name

Returned Values

Property Type Description
isOnline boolean Browser connection status
isServerReachable boolean/null Server reachable (health route)
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 string Server time of the last complete synchronization
pendingCount number Number of pending changes
conflictsCount number Number of unresolved conflicts
syncError Error Last error encountered
register function Register the client without waiting for the first synchronization
sync function Push then pull
push function Push only
pull function Pull only
create function Create an entity (offline-capable)
update function Update 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 Local engine state
getServerStatus function Server-side state of the client (GET sync/status)
reset function Erase all local synchronization data

Create Entity

function CreateThirdpartyForm() {
    const { create, pendingCount } = useSyncClient({
        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({
        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({
        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({
        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({
        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: 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
  • 'merged': merge, with the merged object as third argument

An update conflict is recorded by SmartAuth (numeric id). A deletion conflict stays local (local:<type>:<id> id) and cannot be merged: 'client' queues the deletion again, 'server' gives it up and fetches the object again.

await resolveConflict(conflict.conflict_id, 'merged', {
    ...conflict.server_data,
    label: conflict.client_data.label
});

useOnlineStatus

Hook to detect online/offline status, with a server probe: by default an unauthenticated HEAD request on the public SmartAuth health route, under the API prefixUrl. Mounted instances share a single request per URL.

Import

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

Simple Usage

function NetworkStatus() {
    // Network state only: no request to the server
    const { isOnline, isOffline } = useOnlineStatus({ healthCheckUrl: null });

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

With a Custom Probe

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 <prefixUrl>/health Probe URL, null to disable it
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: answer below 500 (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

useCachedQuery and useAuthenticatedImage need Dexie stores. They are declared in the single application database, src/db/index.js, not in a component (see Data storage):

// src/db/index.js
import { Db } from '@cap-rel/smartcommon';

export const db = new Db({
    name: 'myApp',
    version: 1,
    stores: {
        // Store for cached queries
        queryCache: 'key',

        // Store for images
        imageCache: 'key',

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

Complete Example: Offline-first Application

import {
    useApi,
    useSyncClient,
    useOnlineStatus,
    useCachedQuery,
    CACHE_STRATEGIES,
    Page,
    Block,
    List,
    ListItem,
    Button
} from '@cap-rel/smartcommon';
// The application database, declared once in src/db/index.js (queryCache
// store). useSyncClient manages its own IndexedDB database (smartauth_sync).
import { db } from 'src/db';

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

    // Default probe: public SmartAuth health route
    const { isOnline } = useOnlineStatus();

    const {
        sync,
        isSyncing,
        pendingCount
    } = useSyncClient({
        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
    });

    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. Client registration also happens on its own, on the first synchronization.

Key Points to Remember

  1. useReferenceSync to browse a reference dataset offline, useSyncClient only to modify it
  2. No token nor URL to pass: everything goes through useApi, and the client registers on its own (device identified beforehand)
  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