There's a particular kind of frustration that comes from entering data in your app, then opening it again only to find everything gone. When building with Rork, data persistence issues tend to cluster around a small number of root causes — most of which are subtle enough to slip past a quick code review.
Having hit several of these myself across different Rork projects, here's a practical breakdown of the patterns you're most likely to encounter, with concrete fixes for each.
Pattern 1: Missing await on AsyncStorage Calls
This is the single most common cause of data not saving. AsyncStorage operations are asynchronous, and without await, your code moves on before the write actually completes. The function returns — but the data never made it to storage.
// No await — execution continues before save is done
const saveUserName = (name: string) => {
AsyncStorage.setItem('userName', name);
console.log('saved\!'); // runs immediately — save may not be complete
};
// Correct: wait for the write to finish
const saveUserName = async (name: string) => {
try {
await AsyncStorage.setItem('userName', name);
console.log('saved\!'); // guaranteed: write is complete here
} catch (error) {
console.error('Save failed:', error);
}
};Rork's AI code generation sometimes omits await on AsyncStorage calls, particularly in event handlers. Always review generated async code — this is one of those subtle differences that produces bugs that are genuinely hard to diagnose, because the app doesn't crash or throw an error.
Pattern 2: useEffect Race Condition Overwrites Stored Data
"Data saves fine, but disappears every time I come back to this screen" is a classic symptom of a race between your load and save effects. On first render, userName starts as an empty string, and if a save effect fires before the load effect completes, it overwrites your persisted data with an empty value.
const [userName, setUserName] = useState('');
// Problematic: save effect fires with '' before load finishes
useEffect(() => {
loadUserName();
}, []);
useEffect(() => {
AsyncStorage.setItem('userName', userName); // '' on first render — overwrites\!
}, [userName]);// Fix: isLoaded flag prevents the initial empty write
const [userName, setUserName] = useState('');
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const load = async () => {
const saved = await AsyncStorage.getItem('userName');
if (saved \!== null) setUserName(saved);
setIsLoaded(true); // only set after load is complete
};
load();
}, []);
useEffect(() => {
if (\!isLoaded) return; // do nothing until load has finished
AsyncStorage.setItem('userName', userName);
}, [userName, isLoaded]);The isLoaded flag is a minimal addition that prevents what can otherwise become a surprisingly difficult-to-trace bug. Once you've been burned by this pattern once, it becomes a reflex to add it.
Pattern 3: Key Typos and Naming Inconsistencies
If data is definitely being written but never comes back on load, the first thing to verify is that the key used for saving and loading is identical. A single character difference — an underscore vs. camelCase, for instance — means you're reading from a key that was never written.
// Mismatch — save and load use different key strings
await AsyncStorage.setItem('user_name', value); // save with underscore
const result = await AsyncStorage.getItem('userName'); // load with camelCase
console.log(result); // null, every time// Solution: centralize all keys as typed constants
const STORAGE_KEYS = {
USER_NAME: 'user_name',
USER_EMAIL: 'user_email',
APP_SETTINGS: 'app_settings',
ONBOARDING_DONE: 'onboarding_done',
} as const;
// Now both save and load reference the same constant
await AsyncStorage.setItem(STORAGE_KEYS.USER_NAME, value);
const result = await AsyncStorage.getItem(STORAGE_KEYS.USER_NAME);This pattern also gives you autocomplete on key names, which eliminates the typo problem at the source. It's a small upfront cost that pays for itself quickly on any project larger than a prototype.
Pattern 4: Storing Objects Without JSON Serialization
AsyncStorage is a string-only store. Passing a JavaScript object directly either throws a TypeScript error or silently converts the value to [object Object] — which is a valid string, so no error is raised, but it's useless on the way back out.
// Incorrect: object saved as "[object Object]"
const settings = { theme: 'dark', notifications: true };
await AsyncStorage.setItem('settings', settings as any);
// Reading it back
const raw = await AsyncStorage.getItem('settings');
JSON.parse(raw\!); // SyntaxError — "Unexpected token [ in JSON"// Correct: serialize before saving, parse after loading
await AsyncStorage.setItem('settings', JSON.stringify(settings));
const raw = await AsyncStorage.getItem('settings');
const parsed = raw ? JSON.parse(raw) : null;A typed helper pair keeps this pattern consistent across your whole app:
const saveJSON = async (key: string, value: unknown): Promise<void> => {
await AsyncStorage.setItem(key, JSON.stringify(value));
};
const loadJSON = async <T>(key: string): Promise<T | null> => {
const raw = await AsyncStorage.getItem(key);
if (\!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null; // handle corrupted data gracefully
}
};
// Type-safe usage
interface AppSettings {
theme: 'light' | 'dark';
notifications: boolean;
}
await saveJSON(STORAGE_KEYS.APP_SETTINGS, { theme: 'dark', notifications: true });
const settings = await loadJSON<AppSettings>(STORAGE_KEYS.APP_SETTINGS);Note the try/catch inside loadJSON — it's worth handling the case where stored data is corrupted or from an older app version with a different schema.
Pattern 5: Apparent Data Loss in Expo Go
Seeing data disappear in Expo Go doesn't always mean there's a bug. When the development server reconnects, React component state resets — but AsyncStorage persists. If your initial load effect runs again and doesn't find what it expects, it may look like data was lost when it wasn't.
Use this debug utility to check exactly what's in storage at any point:
const debugStorage = async (): Promise<void> => {
const keys = await AsyncStorage.getAllKeys();
console.log('Keys in storage:', keys);
const pairs = await AsyncStorage.multiGet(keys);
pairs.forEach(([key, value]) => {
console.log(` ${key}:`, value);
});
};Wire this to a debug button during development. Seeing the actual storage state makes it much easier to tell whether the problem is in writing, reading, or somewhere in your state management.
Pattern 6: AsyncStorage Slowdowns and Failures at Scale
AsyncStorage is reliable for storing a handful of values — user preferences, session tokens, small amounts of cached data. But when you start storing hundreds of records, or writing frequently during scroll or input events, it can become a bottleneck.
MMKV is the practical upgrade path:
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
// Synchronous API — no await, significantly faster
storage.set('userName', 'Masaki');
const name = storage.getString('userName'); // 'Masaki', immediately
// JSON objects still need manual serialization
storage.set('settings', JSON.stringify({ theme: 'dark' }));
const raw = storage.getString('settings');
const settings = raw ? JSON.parse(raw) : null;Rork generates AsyncStorage by default, but you can prompt the AI to switch: "Replace AsyncStorage with MMKV for all local storage in this component." For a fuller comparison of storage options including SQLite for relational data, AsyncStorage vs MMKV vs SQLite: When to Use Each covers the tradeoffs in detail.
Debugging Checklist
When data isn't persisting, run through these steps before going deeper:
- Confirm the write works: after saving, immediately read the key back and log it
- Check for exceptions: add
try/catchand log any errors from AsyncStorage calls - Inspect actual storage: use
getAllKeys()to see every key currently stored - Audit every
await: grep forAsyncStorage.setItemand verify each one is awaited - Check useEffect ordering: trace the sequence of effects on first render
In my experience, the missing await accounts for roughly half of all data persistence bugs in Rork apps. Check that first.
Designing for Reliability from the Start
The patterns above are reactive fixes. If you're building something where data integrity really matters — task managers, health trackers, journaling apps — it's worth designing an offline-first persistence layer upfront rather than adding it later. The offline-first app design guide walks through an architecture that handles persistence, sync, and conflict resolution in a structured way.
Start with the checklist above. In most cases, you'll find the cause before you get to the end.