A complete offline-first sync solution that combines the power of Convex (real-time backend), RxDB (local database), and TanStack DB (reactive state management) into a clean, type-safe, and composable API.
- β Offline-first - Works without internet, syncs when reconnected
- β Real-time sync - Convex stream-based bidirectional synchronization
- β Type-safe - Full TypeScript support throughout the pipeline
- β Composable - One API works with any Convex table
- β Conflict resolution - Server-wins strategy with automatic handling
- β Cross-tab sync - Changes sync across browser tabs
- β Hot reload safe - Proper cleanup during development
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β React β β TanStack DB β β RxDB β
β Components βββββΊβ Collections βββββΊβ Local Storage β
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββ βββββββββββββββββββ
β Sync Engine βββββΊβ Replication β
β (Our API) β β State Machine β
βββββββββββββββββββ βββββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββ βββββββββββββββββββ
β Convex Streams βββββΊβ Change Stream β
β Real-time β β Detection β
βββββββββββββββββββ βββββββββββββββββββ
β
βΌ
βββββββββββββββββββ
β Convex β
β Cloud Database β
βββββββββββββββββββ
bun install
# The following packages are required:
# - @tanstack/react-db
# - @tanstack/rxdb-db-collection
# - rxdb
# - convexbunx convex import --table tasks sampleData.jsonlbun run devFor any table you want to sync, create these three Convex functions:
// convex/yourTable.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Change stream for real-time updates
export const changeStream = query({
args: {},
handler: async (ctx) => {
const allItems = await ctx.db.query("yourTable").order("desc").collect();
const latestTime = allItems.length > 0 ? allItems[0].updatedTime : Date.now();
return {
timestamp: latestTime,
count: allItems.length
};
},
});
// Pull documents for replication
export const pullDocuments = query({
args: {
checkpointTime: v.number(),
limit: v.number(),
},
handler: async (ctx, { checkpointTime, limit }) => {
const items = await ctx.db
.query("yourTable")
.filter((q) => q.gt(q.field("updatedTime"), checkpointTime))
.order("desc")
.take(limit);
return items.map(item => ({
id: item.id,
// ... other fields
updatedTime: item.updatedTime
}));
},
});
// Push documents for replication
export const pushDocuments = mutation({
args: {
changeRows: v.array(v.object({
newDocumentState: v.object({
id: v.string(),
// ... your fields
updatedTime: v.number(),
_deleted: v.optional(v.boolean())
}),
assumedMasterState: v.optional(v.object({
id: v.string(),
// ... your fields
updatedTime: v.number(),
_deleted: v.optional(v.boolean())
}))
}))
},
handler: async (ctx, { changeRows }) => {
// Handle conflicts and updates
// See convex/tasks.ts for complete implementation
return conflicts;
},
});// src/useYourTable.ts
import React from "react";
import { createConvexSync, type RxJsonSchema } from "./sync/createConvexSync";
import { useConvexSync } from "./sync/useConvexSync";
import { api } from "../convex/_generated/api";
// Define your data type
export type YourItem = {
id: string;
name: string; // Your custom fields
description: string;
updatedTime: number; // Required for sync
_deleted?: boolean; // Required for soft deletes
};
// Define RxDB schema
const yourSchema: RxJsonSchema<YourItem> = {
title: 'YourItem Schema',
version: 0,
type: 'object',
primaryKey: 'id',
properties: {
id: { type: 'string', maxLength: 100 },
name: { type: 'string' },
description: { type: 'string' },
updatedTime: {
type: 'number',
minimum: 0,
maximum: 8640000000000000,
multipleOf: 1
}
},
required: ['id', 'name', 'description', 'updatedTime'],
indexes: [['updatedTime', 'id']]
};
// Sync instance management (singleton pattern)
let syncInstance: Promise<any> | null = null;
async function getYourSync() {
if (!syncInstance) {
syncInstance = createConvexSync<YourItem>({
tableName: 'yourTable',
schema: yourSchema,
convexApi: {
changeStream: api.yourTable.changeStream,
pullDocuments: api.yourTable.pullDocuments,
pushDocuments: api.yourTable.pushDocuments
}
});
}
return syncInstance;
}
// Main hook
export function useYourTable() {
const [syncInstance, setSyncInstance] = React.useState<any>(null);
React.useEffect(() => {
getYourSync().then(setSyncInstance);
}, []);
const syncResult = useConvexSync<YourItem>(syncInstance);
if (!syncInstance) {
return {
data: [],
isLoading: true,
error: 'Initializing...',
actions: {
insert: async () => { throw new Error('Not initialized'); },
update: async () => { throw new Error('Not initialized'); },
delete: async () => { throw new Error('Not initialized'); }
}
};
}
return syncResult;
}// src/components/YourComponent.tsx
import { useYourTable } from '../useYourTable';
export function YourComponent() {
const { data, isLoading, error, actions } = useYourTable();
const handleCreate = async () => {
await actions.insert({
name: "New item",
description: "Item description"
// id and updatedTime are auto-generated
});
};
const handleUpdate = async (id: string) => {
await actions.update(id, {
name: "Updated name"
});
};
const handleDelete = async (id: string) => {
await actions.delete(id);
};
if (error) return <div>Error: {error}</div>;
if (isLoading) return <div>Loading...</div>;
return (
<div>
<button onClick={handleCreate}>Create Item</button>
{data.map(item => (
<div key={item.id}>
<h3>{item.name}</h3>
<p>{item.description}</p>
<button onClick={() => handleUpdate(item.id)}>Update</button>
<button onClick={() => handleDelete(item.id)}>Delete</button>
</div>
))}
</div>
);
}- Local Changes: User modifies data in React component
- TanStack DB: Reactive collection updates immediately (optimistic UI)
- RxDB: Local database stores the change
- Replication: Push changes to Convex in the background
- Convex Streams: Convex broadcasts changes via reactive queries to all connected clients
- Change Detection: Other clients detect changes via Convex query streams
- Pull Sync: Clients pull new data from Convex
- Conflict Resolution: Server-wins strategy resolves any conflicts
- β Writes: Queue locally in RxDB, sync when online
- β Reads: Always work from local RxDB cache
- β UI: Remains fully functional with optimistic updates
- β Conflicts: Automatically resolved when reconnected
- β Local changes: Sync instantly across browser tabs
- β Remote changes: Propagate via Convex streams to all tabs
- β Database sharing: Single RxDB instance shared across tabs
Creates a sync instance for any Convex table.
interface ConvexSyncConfig<T> {
tableName: string;
schema: RxJsonSchema<T>;
convexApi: {
changeStream: any; // Convex function reference
pullDocuments: any; // Convex function reference
pushDocuments: any; // Convex function reference
};
databaseName?: string; // Default: `${tableName}db`
batchSize?: number; // Default: 100
retryTime?: number; // Default: 5000
enableLogging?: boolean; // Default: true
}Generic React hook for using any sync instance.
interface UseConvexSyncResult<T> {
data: T[]; // Reactive data array
isLoading: boolean; // Loading state
error?: string; // Error message if any
collection: any | null; // TanStack collection instance
actions: {
insert: (itemData: Omit<T, 'id' | 'updatedTime' | '_deleted'>) => Promise<string>;
update: (id: string, updates: Partial<Omit<T, 'id' | 'updatedTime' | '_deleted'>>) => Promise<void>;
delete: (id: string) => Promise<void>;
};
}Your data types must include these fields:
type YourData = {
id: string; // Primary key (auto-generated)
updatedTime: number; // For replication (auto-generated/updated)
_deleted?: boolean; // For soft deletes (managed automatically)
// ... your custom fields
};The included task manager demonstrates the sync engine:
# Install dependencies
bun install
# Import sample data
bunx convex import --table tasks sampleData.jsonl
# Start development server
bun run devEnable detailed logging:
const syncInstance = createConvexSync({
// ... other config
enableLogging: true // Detailed console logging
});- Open the app in your browser
- Open DevTools β Network tab
- Set throttling to "Offline"
- Continue using the app normally
- Set back to "Online" to see sync resume
Before:
// Complex manual setup
const db = await createRxDatabase(/* complex config */);
const collection = await db.addCollections(/* schemas */);
const replication = replicateRxCollection(/* complex replication setup */);After:
// Simple one-line setup
const syncInstance = await createConvexSync({
tableName: 'yourTable',
schema: yourSchema,
convexApi: api.yourTable
});The sync engine provides a complete replacement for:
- Redux/Zustand: TanStack DB handles reactive state
- React Query: Built-in caching and background sync
- Manual real-time connections: Automatic Convex stream updates
- Local storage: RxDB provides structured local database
src/useTasks.ts- Complete task management implementation with CRUD operationssrc/sync/README.md- Detailed API documentation with advanced examples
This sync engine demonstrates a complete offline-first architecture. Feel free to extend it for your specific use cases or contribute improvements to the core sync logic.
MIT License - see the existing license in this repository.