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