You open the app on the subway. White screen. The spinner keeps spinning. Five seconds later: "Network Error." The user closes the app and never comes back. One-star reviews pile up on the App Store.
Apps built with Rork often rely on API data for their primary views. But an app that ignores unreliable network conditions is unusable in the real world, no matter how polished its features are. Offline support is not a nice-to-have — it is foundational infrastructure for app survival.
AsyncStorage vs SQLite: Choosing the Right Tool
React Native (the framework underlying Rork's generated code) offers two primary options for data persistence: AsyncStorage and SQLite. Choosing wrong means a painful migration later.
Use AsyncStorage for:
- User preferences (theme, notification toggles, language)
- Authentication tokens
- Last-viewed screen IDs
- Small JSON payloads under 10KB
Use SQLite for:
- Structured data like article lists or product catalogs
- Data that needs searching or filtering
- Lists exceeding 100 items
- Data with relational structure
The decision rule is simple: "Will I need to query this data conditionally?" If yes, use SQLite. If no, use AsyncStorage. AsyncStorage is a key-value store and is unsuitable for queries like "items in category X created after date Y."
Basic AsyncStorage caching pattern for a Rork-generated app:
import AsyncStorage from '@react-native-async-storage/async-storage';
// Write cache with TTL
const cacheData = async (key: string, data: unknown, ttlMinutes: number = 60) => {
const cacheEntry = {
data,
timestamp: Date.now(),
ttl: ttlMinutes * 60 * 1000,
};
await AsyncStorage.setItem(key, JSON.stringify(cacheEntry));
};
// Read cache with expiry check
const getCachedData = async <T>(key: string): Promise<T | null> => {
const raw = await AsyncStorage.getItem(key);
if (\!raw) return null;
const entry = JSON.parse(raw);
const isExpired = Date.now() - entry.timestamp > entry.ttl;
if (isExpired) {
await AsyncStorage.removeItem(key);
return null;
}
return entry.data as T;
};TTL (Time To Live) calibration matters. Too short and data disappears when users go offline; too long and stale content lingers. From experience, news feeds work well at 30 minutes, user profiles at 24 hours, and master data at 7 days.
SQLite for Offline List Screens
List screens with 100+ items need SQLite. Here is how to integrate expo-sqlite into a Rork project:
import * as SQLite from 'expo-sqlite';
const db = SQLite.openDatabaseSync('app_cache.db');
// Initialize tables (run once at app startup)
const initDatabase = () => {
db.execSync(`
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
category TEXT,
content TEXT,
cached_at INTEGER,
is_synced INTEGER DEFAULT 1
)
`);
db.execSync(`
CREATE INDEX IF NOT EXISTS idx_articles_category
ON articles(category)
`);
};
// Batch cache (API response → SQLite)
const cacheArticles = (articles: Article[]) => {
const now = Date.now();
const stmt = db.prepareSync(
'INSERT OR REPLACE INTO articles (id, title, category, content, cached_at) VALUES (?, ?, ?, ?, ?)'
);
db.execSync('BEGIN TRANSACTION');
for (const article of articles) {
stmt.executeSync(article.id, article.title, article.category, article.content, now);
}
db.execSync('COMMIT');
};
// Category-filtered retrieval (fast search even offline)
const getArticlesByCategory = (category: string): Article[] => {
return db.getAllSync<Article>(
'SELECT * FROM articles WHERE category = ? ORDER BY cached_at DESC',
category
);
};Wrapping bulk inserts in BEGIN TRANSACTION and COMMIT is critical. Inserting 100 rows individually takes around 5 seconds; with a transaction wrapper, it drops below 0.1 seconds. This is a major performance factor for Rork-generated apps.
UI State Management While Offline
Caching data alone is not enough. Users need to know they are viewing cached content:
import NetInfo from '@react-native-community/netinfo';
import { useEffect, useState, useCallback } from 'react';
const useNetworkAwareData = <T>(
fetchFn: () => Promise<T>,
cacheKey: string
) => {
const [data, setData] = useState<T | null>(null);
const [source, setSource] = useState<'network' | 'cache' | 'none'>('none');
const [isRefreshing, setIsRefreshing] = useState(false);
const loadData = useCallback(async () => {
const netState = await NetInfo.fetch();
if (netState.isConnected) {
try {
setIsRefreshing(true);
const freshData = await fetchFn();
setData(freshData);
setSource('network');
await cacheData(cacheKey, freshData);
} catch {
// Network available but API is down
const cached = await getCachedData<T>(cacheKey);
if (cached) {
setData(cached);
setSource('cache');
}
} finally {
setIsRefreshing(false);
}
} else {
// Offline: read from cache
const cached = await getCachedData<T>(cacheKey);
if (cached) {
setData(cached);
setSource('cache');
}
}
}, [fetchFn, cacheKey]);
useEffect(() => { loadData(); }, [loadData]);
return { data, source, isRefreshing, refresh: loadData };
};The source field is the key design element. Use it to show a contextual indicator:
const ArticleList = () => {
const { data, source, isRefreshing, refresh } = useNetworkAwareData(
fetchArticles,
'articles_list'
);
return (
<View>
{source === 'cache' && (
<View style={styles.offlineBanner}>
<Text>Offline mode — showing last fetched data</Text>
</View>
)}
<FlatList
data={data}
refreshing={isRefreshing}
onRefresh={refresh}
renderItem={({ item }) => <ArticleCard article={item} />}
/>
</View>
);
};Optimistic Updates: Writing While Offline
Beyond reads, write operations (likes, bookmarks, comments) should also work offline. This is where the optimistic update pattern comes in:
const usePendingActions = () => {
const addPendingAction = async (action: PendingAction) => {
const pending = await getCachedData<PendingAction[]>('pending_actions') || [];
pending.push({ ...action, createdAt: Date.now() });
await cacheData('pending_actions', pending, 7 * 24 * 60); // Keep for 7 days
};
const syncPendingActions = async () => {
const pending = await getCachedData<PendingAction[]>('pending_actions') || [];
const failed: PendingAction[] = [];
for (const action of pending) {
try {
await executeAction(action);
} catch {
failed.push(action);
}
}
await cacheData('pending_actions', failed, 7 * 24 * 60);
return { synced: pending.length - failed.length, failed: failed.length };
};
return { addPendingAction, syncPendingActions };
};On the UI side, reflect the user's action immediately and sync in the background:
const handleLike = async (articleId: string) => {
// 1. Update UI instantly (optimistic)
setLiked(true);
setLikeCount(prev => prev + 1);
// 2. Queue the pending action
await addPendingAction({
type: 'like',
payload: { articleId },
});
};When the network returns, syncPendingActions sends queued operations to the server. Ideally, use a NetInfo listener to detect reconnection and trigger sync automatically.
Your Next Move
Start by caching data for your most-visited screen using AsyncStorage. That alone eliminates the "white screen on the subway" problem. Offline write support can be introduced incrementally once read caching is stable.