Offline-first interval tracking app for React Native using Replicate with native SQLite persistence.
- Native SQLite persistence using
op-sqliteinstead of sql.js WASM - Plain text editing with
useProseFieldhook for Y.XmlFragment binding - Crypto polyfill setup required for React Native (
react-native-get-random-values) - Metro bundler configuration for Yjs/lib0 deduplication
- Collection initialization pattern with
PersistenceGatecomponent
This example uses native modules that require a development build (not Expo Go):
@op-engineering/op-sqlite- Native SQLite bindingsreact-native-get-random-values- Crypto polyfill with native RNGreact-native-random-uuid- UUID generation
You must run expo prebuild to generate the native ios/ and android/ directories before building.
# Install dependencies
bun install
# Set up environment
cp .env.example .env.local
# Add your EXPO_PUBLIC_CONVEX_URL
# Generate native projects (required for native modules)
bun run prebuild
# Deploy Convex functions
bunx convex dev
# Run on iOS (requires Xcode)
bun run ios
# Run on Android (requires Android Studio)
bun run androidNote: You cannot use Expo Go with this example. The native modules require a custom dev client built via
expo run:iosorexpo run:android.
| File | Purpose |
|---|---|
src/collections/useIntervals.ts |
Native SQLite setup with persistence.sqlite.native() |
src/hooks/useProseField.ts |
Y.XmlFragment to TextInput binding with debounced sync |
src/contexts/IntervalsContext.tsx |
Collection initialization with PersistenceGate pattern |
app/_layout.tsx |
Crypto polyfill imports (must be first) |
metro.config.js |
Yjs/lib0 deduplication to prevent duplicate module errors |
src/types/interval.ts |
Zod schema with prose() field type |
React Native lacks ProseMirror/TipTap support, so this example uses a custom useProseField hook that:
- Binds a Y.XmlFragment to a plain
TextInput - Converts Y.XmlFragment to/from plain text on the fly
- Debounces updates (1 second) to avoid excessive CRDT operations
- Observes remote changes and updates the TextInput
// Usage in a component
const { text, isReady, handleChangeText } = useProseField(intervalId);
<TextInput
value={text}
onChangeText={handleChangeText}
editable={isReady}
/>This approach sacrifices rich text formatting but maintains full CRDT sync compatibility with web clients using TipTap.
| Aspect | Web (TanStack Start/SvelteKit) | React Native (Expo) |
|---|---|---|
| Persistence | sql.js WASM + OPFS |
op-sqlite (native SQLite) |
| Rich Text | TipTap editor with ProseMirror | Plain TextInput + useProseField |
| Crypto | Browser native | react-native-get-random-values polyfill |
| Bundler | Vite | Metro with yjs/lib0 deduplication |
| SSR | Server-side hydration via material |
N/A - client-only |
// src/collections/useIntervals.ts
import { collection, persistence } from '@trestleinc/replicate/client';
import { open } from '@op-engineering/op-sqlite';
export const intervals = collection.create({
persistence: async () => {
const db = open({ name: 'intervals.db' });
return persistence.sqlite.native(db, 'intervals');
},
config: () => ({
/* ... */
}),
});The metro.config.js forces single copies of yjs and lib0 to prevent duplicate import errors that occur when dependencies bundle their own copies:
// Force single copies of yjs and lib0
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName === 'yjs') {
return {
filePath: path.resolve(projectRoot, 'node_modules/yjs/dist/yjs.mjs'),
type: 'sourceFile',
};
}
// Similar handling for lib0...
};React Native requires crypto polyfills for Yjs. Import at the very top of app/_layout.tsx:
// Must be first imports!
import 'react-native-get-random-values';
import 'react-native-random-uuid';
// Then other imports...See the main README for full Replicate documentation, including:
- Architecture and data flow
- Server-side schema and replication setup
- API reference
- Sync protocol details