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
useSyncClientis 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
- useSyncClient for offline-capable CRUD operations
- register(deviceUuid) before any synchronization, no exception
- useOnlineStatus to detect connectivity
- useCachedQuery for smart caching with strategies
- useAuthenticatedImage for protected images
- ConflictResolver for conflict resolution UI
- Configure IndexedDB stores for caching, keeping in mind that
useSyncClientmanages its own separate database