Persistence and device APIs

Choose between AsyncStorage, MMKV and SecureStore, then use camera, location, notifications and the file system with permissions handled at the point of use.

Choosing a storage layer

StorePerformanceUse for
AsyncStorageAsync, JSON stringsSmall amounts of non-sensitive data
react-native-mmkvSynchronous, very fastFrequently read preferences and cached state
expo-secure-storeKeychain and KeystoreTokens, refresh tokens, secrets
react-native-fsFile APIsImages, downloaded documents, exports
SQLite or WatermelonDBRelationalLarge structured datasets with queries
import { MMKV } from 'react-native-mmkv';
import * as SecureStore from 'expo-secure-store';

const storage = new MMKV({ id: 'app-cache' });

export const settings = {
  get theme() {
    return storage.getString('theme') ?? 'system';
  },
  setTheme(value: 'light' | 'dark' | 'system') {
    storage.set('theme', value);
  },
};

export async function saveSession(token: string, refresh: string) {
  await SecureStore.setItemAsync('access_token', token, {
    keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
  });
  await SecureStore.setItemAsync('refresh_token', refresh);
}

export async function clearSession() {
  await SecureStore.deleteItemAsync('access_token');
  await SecureStore.deleteItemAsync('refresh_token');
}

MMKV is synchronous, which is its main advantage: reading a stored theme during the first render needs no loading state and causes no flash of the wrong colour.

Camera, location and files

import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location';
import * as FileSystem from 'expo-file-system';

export function Scanner({ onScanned }: { onScanned: (data: string) => void }) {
  const [permission, requestPermission] = useCameraPermissions();

  if (!permission) return null;                        // still loading
  if (!permission.granted) {
    return (
      <View>
        <Text>Camera access is needed to scan codes.</Text>
        {permission.canAskAgain ? (
          <Button title="Allow camera" onPress={requestPermission} />
        ) : (
          <Button title="Open settings" onPress={() => Linking.openSettings()} />
        )}
      </View>
    );
  }

  return (
    <CameraView
      style={{ flex: 1 }}
      barcodeScannerSettings={{ barcodeTypes: ['qr', 'ean13'] }}
      onBarcodeScanned={({ data }) => onScanned(data)}
    />
  );
}

export async function exportJson(name: string, payload: unknown) {
  const dir = FileSystem.documentDirectory ?? FileSystem.cacheDirectory!;
  const uri = dir + name + '.json';
  await FileSystem.writeAsStringAsync(uri, JSON.stringify(payload));
  return uri;
}
  • Denied-forever is a different state from denied: offer a link to system settings instead of asking again, because the prompt will not appear.
  • Request permissions at the moment the feature is used, with a sentence explaining why, not on first launch.
  • Write user documents to documentDirectory and regenerable files to cacheDirectory, which the system may clear.
  • Stop location updates when the screen loses focus, or you drain the battery and appear in the background-usage review.

Notifications and background work

import * as Notifications from 'expo-notifications';

export async function registerForPush(): Promise<string | null> {
  const existing = await Notifications.getPermissionsAsync();
  let status = existing.status;

  if (status !== 'granted') {
    status = (await Notifications.requestPermissionsAsync()).status;
  }
  if (status !== 'granted') return null;

  const token = (await Notifications.getExpoPushTokenAsync()).data;
  await request('/devices/push-token', { method: 'POST', body: JSON.stringify({ token }) });
  return token;
}

Notifications.addNotificationResponseReceivedListener((response) => {
  const deepLink = response.notification.request.content.data?.url as string | undefined;
  if (deepLink) router.push(deepLink);
});
⚠️
Android 13 and later requires the runtime POST_NOTIFICATIONS permission and iOS shows the system prompt only once. Ask after the user has done something that makes notifications obviously useful, or you permanently lose the chance.

FAQ

Is AsyncStorage safe for a login token?
No. It is unencrypted and readable from a device backup. Use SecureStore or the Keychain for anything that grants access, and keep AsyncStorage for preferences and cached content.
How do I debug a permission that is always denied?
The grant persists until the app is uninstalled or the user changes it in settings. Uninstall and reinstall, or reset the permission for the app in the device settings, before testing the prompt path again.

Networking, data fetching and offline support Native modules, TurboModules and the New Architecture

Last refreshed 2026-09-18.