tyofflinejs
v0.2.0
Published
Cross-platform offline-first module for React and React Native with pluggable storage/network adapters
Downloads
518
Maintainers
Readme
tyofflinejs
A cross-platform, tree-shakeable offline-first module for React and React Native. Pure TypeScript core with pluggable storage and network adapters.
If this project helps you, consider buying me a coffee.
Features
- Cross-platform - same API for React (web) and React Native (mobile)
- Pure TypeScript core - zero platform dependencies in the engine
- Pluggable adapters - storage via IndexedDB, AsyncStorage, MMKV, SQLite (KV table), or custom
IStorageAdapter; pluggable network detection - Pending queue - operations are queued when offline and synced when connectivity returns
- Conflict resolution - built-in strategies (client-wins, server-wins, last-write-wins, merge, manual) or provide your own
- React hooks -
useOfflineQuery,useOfflineMutation,useOfflineStatus,useSyncStatus,usePendingQueue - Tree-shakeable - import only what you need; web apps never bundle React Native code
- Type-safe - full generic typing across the entire API
Installation
npm install tyofflinejsWeb (React)
No additional dependencies required for MemoryAdapter. For persistent storage:
# IndexedDB adapter works out of the box in browsersReact Native
npm install @react-native-async-storage/async-storage @react-native-community/netinfoOptional storage drivers (pick one or more in the app): react-native-mmkv for MmkvAdapter, and a SQLite library (for example expo-sqlite) for SqliteKvAdapter — see docs/native-storage-adapters.md.
Documentation
- docs/README.md — topic index (native storage, and future guides).
- Root README — install, quick start, adapter summary, configuration.
When contributing new features, update both the README and the relevant docs/ page in the same change (see .cursor/rules/feature-documentation.mdc).
Quick Start
1. Define your sync executor
The sync executor is how the module communicates with your backend:
import type { SyncExecutor, PendingAction, Result } from 'tyofflinejs';
const syncExecutor: SyncExecutor = {
async execute(action: PendingAction): Promise<Result<unknown>> {
try {
const response = await fetch(`/api/${action.entity}`, {
method: action.type === 'delete' ? 'DELETE' : action.type === 'create' ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.payload),
});
if (!response.ok) {
return { ok: false, error: new Error(`HTTP ${response.status}`) };
}
return { ok: true, value: await response.json() };
} catch (error) {
return { ok: false, error: error as Error };
}
},
};2. Configure and wrap your app
Web (React)
import { OfflineProvider } from 'tyofflinejs';
import { IndexedDBAdapter, WebNetworkAdapter } from 'tyofflinejs/web';
const config = {
storage: new IndexedDBAdapter(),
network: new WebNetworkAdapter({ pingUrl: '/api/health' }),
syncExecutor: syncExecutor,
syncInterval: 30000,
conflictStrategy: 'last-write-wins' as const,
};
function App() {
return (
<OfflineProvider config={config}>
<YourApp />
</OfflineProvider>
);
}React Native
import { OfflineProvider } from 'tyofflinejs';
import { AsyncStorageAdapter, RNNetworkAdapter } from 'tyofflinejs/native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
const config = {
storage: new AsyncStorageAdapter(AsyncStorage),
network: new RNNetworkAdapter(NetInfo),
syncExecutor: syncExecutor,
syncInterval: 30000,
conflictStrategy: 'last-write-wins' as const,
};
function App() {
return (
<OfflineProvider config={config}>
<YourApp />
</OfflineProvider>
);
}3. Use the hooks
import {
useOfflineStatus,
useOfflineMutation,
useOfflineQuery,
useSyncStatus,
usePendingQueue,
} from 'tyofflinejs';
function TaskList() {
const { isOnline } = useOfflineStatus();
const { data: tasks, isLoading } = useOfflineQuery<Task[]>(
'tasks',
() => fetch('/api/tasks').then(r => r.json()),
);
const { mutate: createTask } = useOfflineMutation<Task>({
entity: 'tasks',
entityId: 'new',
type: 'create',
});
const { status, lastSyncAt } = useSyncStatus();
const { pendingCount } = usePendingQueue();
return (
<div>
<p>Status: {isOnline ? 'Online' : 'Offline'}</p>
<p>Sync: {status} | Pending: {pendingCount}</p>
<button onClick={() => createTask({ title: 'New Task' })}>
Add Task
</button>
{isLoading ? <p>Loading...</p> : tasks?.map(t => <div key={t.id}>{t.title}</div>)}
</div>
);
}API Reference
Core
| Export | Description |
|--------|------------|
| OfflineEngine | Main orchestrator - manages queue, sync, and adapters |
| PendingQueue | Pending operations queue with deduplication and ordering |
| SyncManager | Processes the queue with retry, backoff, and conflict resolution |
| ConflictResolver | Pluggable conflict resolution strategies |
| EventBus | Typed event emitter for decoupled communication |
Hooks
| Hook | Returns | Description |
|------|---------|-------------|
| useOfflineStatus() | { isOnline, checkNow } | Current network status |
| useOfflineQuery<T>(key, fetcher?) | { data, isLoading, error, refetch } | Cache-first data reading |
| useOfflineMutation<T>(options) | { mutate, isLoading, error, lastAction, reset } | Write with offline queue |
| useSyncStatus() | SyncProgress | Sync progress (status, total, completed, failed, lastSyncAt) |
| usePendingQueue() | { actions, pendingCount, failedCount, clearQueue, retryFailed } | Queue visibility and control |
Adapters
| Adapter | Platform | Import Path |
|---------|----------|-------------|
| MemoryAdapter | Universal | tyofflinejs |
| IndexedDBAdapter | Web | tyofflinejs/web |
| WebNetworkAdapter | Web | tyofflinejs/web |
| AsyncStorageAdapter | React Native | tyofflinejs/native |
| MmkvAdapter | React Native (MMKV) | tyofflinejs/native |
| SqliteKvAdapter | React Native (SQLite KV) | tyofflinejs/native |
| RNNetworkAdapter | React Native | tyofflinejs/native |
Configuration
interface OfflineConfig {
storage: IStorageAdapter; // Required: storage adapter
network: INetworkAdapter; // Required: network adapter
syncExecutor: SyncExecutor; // Required: how to sync with backend
syncInterval?: number; // Auto-sync interval (ms). 0 = disabled. Default: 30000
maxRetries?: number; // Max retry attempts per action. Default: 3
retryBackoff?: 'linear' | 'exponential'; // Retry delay strategy
conflictStrategy?: ConflictStrategy; // Default: 'last-write-wins'
onConflict?: ConflictHandler; // Custom conflict handler
onSyncError?: (error: Error, action: PendingAction) => void;
cooldownMs?: number; // Min time between syncs. Default: 5000
}Conflict Strategies
| Strategy | Behavior |
|----------|----------|
| client-wins | Local change always wins |
| server-wins | Remote data always wins (local discarded) |
| last-write-wins | Most recent timestamp wins |
| merge | Shallow merge of local + remote payloads |
| manual | Returns null - requires custom onConflict handler |
Events
Subscribe to engine events for fine-grained control:
const engine = useEngine();
engine.on('network:online', () => console.log('Back online'));
engine.on('network:offline', () => console.log('Gone offline'));
engine.on('sync:start', () => console.log('Sync started'));
engine.on('sync:complete', (progress) => console.log('Sync done', progress));
engine.on('sync:error', ({ error, action }) => console.error('Sync failed', error));
engine.on('sync:conflict', (ctx) => console.warn('Conflict detected', ctx));
engine.on('queue:added', (action) => console.log('Queued', action));Custom Adapters
Built-in native options include MmkvAdapter and SqliteKvAdapter — see docs/native-storage-adapters.md.
For other backends, implement IStorageAdapter or INetworkAdapter:
import type { IStorageAdapter } from 'tyofflinejs';
class CustomStorageAdapter implements IStorageAdapter {
async get<T>(key: string): Promise<T | null> { /* ... */ }
async set<T>(key: string, value: T): Promise<void> { /* ... */ }
async remove(key: string): Promise<void> { /* ... */ }
async getAllKeys(): Promise<string[]> { /* ... */ }
async multiGet<T>(keys: string[]): Promise<Map<string, T>> { /* ... */ }
async clear(): Promise<void> { /* ... */ }
}Support
If you find this project useful, you can support its development:
License
MIT
