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
import { useSyncClient } from '@cap-rel/smartcommon';
Configuration
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
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) => {
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
{
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
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: 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
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
import { useAuthenticatedImage } from '@cap-rel/smartcommon';
Usage
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:
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 {
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
- useSyncClient for offline-capable CRUD operations
- useOnlineStatus to detect connectivity
- useCachedQuery for smart caching with strategies
- useAuthenticatedImage for protected images
- ConflictResolver for conflict resolution UI
- Configure IndexedDB stores for caching
Previous Chapter | Back to Module