---
source_hash: "7e64af0d"
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

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,
        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

```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) => {
        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

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

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

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

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

### Usage

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

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