---
source_hash: "aff5f847"
title: "Chapter 4: Offline Synchronization"
weight: 610
---

# 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](/front/synchronisation), which compares the three
> available mechanisms.

## useSyncClient

Main hook for offline-first synchronization.

### Import

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

### Configuration

```javascript
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.

```javascript
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

```javascript
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

```javascript
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.

```javascript
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

```javascript
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

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

### Usage

```javascript
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

```javascript
{
    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

```javascript
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

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

### Simple Usage

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

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

### With Server Health Check

```javascript
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

```javascript
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

```javascript
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

```javascript
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

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

### Usage

```javascript
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:

```javascript
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

```javascript
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](/training/module7-smartcommon-hooks/utilitaires) | [Back to Module](/training/module7-smartcommon-hooks)
