Skip to main content

Recipes

Task-shaped answers that span providers.

Assumed setup

Most recipes below pass an mmkvAdapter and write locally through mmkv. Both are defined once:

import { MMKV } from 'react-native-mmkv'

export const mmkv = new MMKV()

/** Makes the outbox survive a restart. See the outbox for why it matters. */
export const mmkvAdapter = {
getString: (key: string) => mmkv.getString(key),
set: (key: string, value: string) => mmkv.set(key, value),
}

MMKV because outboxStorage is read on the write path and so must be synchronous - see making the outbox durable.

Back up and restore user data

const store = createCloudStore({
providers: ['icloudKV', 'googleDrive'],
tiering: 'auto',
outboxStorage: mmkvAdapter,
})

const KEY = 'backup/v1'

export async function backup (state: AppState) {
await store.setItem(KEY, JSON.stringify({ ...state, updatedAt: Date.now() }))
}

export async function restore (): Promise<AppState | null> {
const raw = await store.getItem(KEY)
return raw == null ? null : (JSON.parse(raw) as AppState)
}

Version the key (backup/v1), not the payload. When the shape changes, write backup/v2 and keep reading v1 as fallback - older devices keep working, and a rollback cannot corrupt anything.

Let the user download their backup

An export saved to Files, AirDropped, or emailed to support - for anything holding records the user considers theirs: a finance app's database, a journal, a workout history.

Three steps, and this package owns the first two:

  1. Get the file out of the cloud to a path you control.
  2. Put it somewhere durable - not the temporary directory.
  3. Hand it to the system so the user picks the destination.
import * as FileSystem from 'expo-file-system'
import * as Sharing from 'expo-sharing'
import { cloudKitBackup, googleDriveFiles } from 'react-native-cloud-sync'

async function exportBackup () {
const stamp = new Date().toISOString().slice(0, 10)
const destinationUri = `${FileSystem.documentDirectory}finance-backup-${stamp}.sqlite`

const path = Platform.OS === 'ios'
? await cloudKitBackup.restore({ destinationUri, onProgress: track })
: await googleDriveFiles.fetch({ name: 'backup.sqlite', destinationUri, onProgress: track })

if (path == null) return showNothingToExport()

await Sharing.shareAsync(path, {
mimeType: 'application/x-sqlite3',
dialogTitle: 'Export backup',
})
}

Always pass destinationUri

Without it, cloudKitBackup.restore() lands the file in the app's temporary directory under a name derived from the record - fine for restoring straight back into the app, wrong for an export, since iOS may reclaim it at any point and the name is not one to show anyone.

documentDirectory is the usual choice: it survives, it is backed up, and the share sheet can read it. Parent directories are created for you.

The sharing step is not ours

expo-sharing, react-native-share and expo-document-picker already solve the system share sheet, "Save to Files", and the import picker. Wrapping them would add a WebView-sized dependency and a worse copy of a solved problem, so this package stops at handing you a real path.

The exception, and a good one on iOS: icloudDocuments puts the file straight into the user's iCloud Drive, showing up in Files.app with no share sheet at all.

await icloudDocuments.save({ fileUri: path, name: `finance-backup-${stamp}.sqlite` })

Exporting is a privacy decision

The moment a file leaves your sandbox it is outside every guarantee this package makes. A backup end-to-end encrypted in cloudKitEncrypted, or behind a codec, is plaintext on disk the instant you write it somewhere the share sheet can reach - whatever the user AirDrops it into is no longer your problem.

For a finance app that usually means encrypting the export itself with a passphrase entered at export time, and saying plainly in the dialog that the file is unencrypted if the user declines. See Encryption for where that key should live.

Importing it back

Worth building at the same time - an export nobody can restore is a false promise:

const picked = await DocumentPicker.getDocumentAsync({ type: 'application/x-sqlite3' })
if (picked.canceled) return

await validateBackupFile(picked.assets[0].uri) // yours: check it is really your schema
await restoreFromFile(picked.assets[0].uri)

Validate before restoring - the file came from outside your app, may be from a much older version, or may not be your file at all.

Large databases

Both fetch paths stream: googleDriveFiles downloads in 8 MiB chunks through the file adapter, cloudKitBackup streams a CKAsset from disk, and neither holds the whole database in memory. Report progress - a multi-hundred-megabyte export with no feedback reads as a hang - and offer a cancel:

await cloudKitBackup.cancel() // rejects the restore with ERR_CANCELLED

Cross-platform large-file backup

That recipe suits a JSON-sized blob. For something too big to hold in memory as a string - a SQLite export, hundreds of MB - cloudKitBackup (iOS/macOS) and googleDriveFiles (Android/web) are the two providers that actually stream from disk. They are not merged into one API because their restore paths genuinely differ - CloudKit invents its own temp path, Drive writes wherever your file adapter says - but wrapping both behind one pair of functions is a few lines:

import { Platform } from 'react-native'
import { cloudKitBackup, googleDriveFiles } from 'react-native-cloud-sync'

const isAppleNative = Platform.OS === 'ios' || Platform.OS === 'macos'

export async function backupLargeFile (
fileUri: string,
onProgress?: (fraction: number) => void
) {
if (isAppleNative)
await cloudKitBackup.save(fileUri, { onProgress: e => onProgress?.(e.fraction) })
else
await googleDriveFiles.save({
name: 'backup',
fileUri,
onProgress: e => onProgress?.(e.fraction),
})
}

export async function restoreLargeFile (
destinationUri: string,
onProgress?: (fraction: number) => void
): Promise<string | null> {
if (isAppleNative)
return cloudKitBackup.restore({ onProgress: e => onProgress?.(e.fraction) })

return googleDriveFiles.fetch({
name: 'backup',
destinationUri,
onProgress: e => onProgress?.(e.fraction),
})
}

On iOS/macOS, restoreLargeFile's destinationUri goes unused - cloudKitBackup.restore returns its own temp path, which is why the parameter exists at all: the caller decides where the file ends up, and on Apple platforms that is "wherever CloudKit already put it," while on Drive it is a real path from configureGoogleDriveFiles's adapter. Move or copy the result into your app's own storage before relying on it long-term - CloudKit's temp location is not guaranteed to survive past the current run.

googleDriveFiles needs configureGoogleDriveFiles called once at startup (see its setup); cloudKitBackup needs nothing beyond the entitlements every other CloudKit call already requires.

Restore safely on first launch

The failure mode to avoid is treating "the cloud errored" as "there is no backup" and then overwriting good remote data with an empty local state.

let remote: string | null
try {
remote = await store.getItem(KEY)
} catch (e) {
// Reached the cloud and it failed, OR could not reach it at all. Either way:
// do NOT seed, do NOT overwrite.
return startWithLocalState({ syncError: e })
}

if (remote == null) startFresh() // genuinely nothing stored
else applyRemote(JSON.parse(remote))

This is only safe because null means one thing. A signed-out user is the dangerous case - nothing is reachable and there is no error to catch, so a store answering null there would send you straight into startFresh(). The facade instead raises ERR_NOT_SIGNED_IN when no configured provider was reachable, reserving null for "at least one provider answered and none had this key". See absent vs broken.

Cancel a large transfer

const controller = new AbortController()

showCancelButton(() => controller.abort())

try {
await googleDriveFiles.save({
name: 'backup.sqlite',
fileUri: localPath,
signal: controller.signal,
onProgress: ({ fraction }) => setProgress(fraction),
})
} catch (e) {
if (isCancelled(e)) return // they asked. Say nothing.
throw e
}

Checked between chunks, so it takes effect mid-transfer rather than after the whole file has moved - the entire point at a few hundred megabytes.

CloudKit assets are cancelled by name instead, because that is what identifies a transfer everywhere else in that API:

await cloudKitBackup.cancel() // the default backup
await cloudKitAssets.cancel({ recordName: 'avatar', fieldName: 'image' })

Both resolve true when there was something to cancel, and the cancelled call rejects with ERR_CANCELLED.

Encrypt what goes to Drive

Drive's appDataFolder is hidden from the user's Drive UI, but it is plaintext to anything holding the account's OAuth token.

const store = createCloudStore({
providers: ['googleDrive'],
codec: {
encode: value => encryptWithDeviceKey(value),
decode: value => decryptWithDeviceKey(value),
},
})

The package ships no cipher of its own - key management is what decides whether this is worth anything, and bundling a cipher would make it look solved. Bring one you chose, and think about where its key lives (Keychain/Keystore, not the same cloud).

Values only, never keys - a getAllKeys() that returned ciphertext would be useless. Tiering measures the encrypted size, so an inflating codec eats into your thresholds.

On Apple platforms, cloudKitEncrypted gives you end-to-end encryption with no key to manage at all. Encryption covers which to pick.

Last-write-wins with a timestamp

Neither CloudKit nor Drive merges for you - the simplest correct policy is to carry a timestamp and compare:

interface Blob { data: AppState, updatedAt: number }

const remoteRaw = await store.getItem(KEY)
const remote = remoteRaw == null ? null : (JSON.parse(remoteRaw) as Blob)

if (remote == null || local.updatedAt > remote.updatedAt)
await store.setItem(KEY, JSON.stringify(local))
else if (remote.updatedAt > local.updatedAt)
applyRemote(remote.data)
// Equal timestamps: nothing to do.

Deletions do not propagate under this scheme - a removed item reappears from whichever device still has it. Store tombstones rather than removing entries if deletions matter.

Two-way sync between Apple and non-Apple devices

import {
createCloudStore,
resolveByTimestamp,
} from 'react-native-cloud-sync'

interface Blob { data: AppState, updatedAt: number }

const store = createCloudStore({
providers: ['icloudKV', 'googleDrive'],
// Put a copy in Drive, so a non-Apple device has something to read.
writeMode: 'mirror',
// Consult both, and take the newest - otherwise an Apple device returns its
// own stale iCloud copy without ever looking at Drive.
resolve: resolveByTimestamp('updatedAt'),
outboxStorage: mmkvAdapter,
})

export async function save (data: AppState) {
const blob: Blob = { data, updatedAt: Date.now() }
await store.setItem('state/v1', JSON.stringify(blob))
}

export async function load (): Promise<AppState | null> {
const raw = await store.getItem('state/v1')
return raw == null ? null : (JSON.parse(raw) as Blob).data
}

Every write must carry the timestamp, on every platform - a value the resolver cannot date loses to one it can, so a device that forgets will always lose.

Clock skew is the limitation: device clocks disagree, so "newest" means "claims the latest timestamp" - fine for a backup blob. Where a lost write matters, merge in resolve rather than picking a winner:

resolve: candidates => JSON.stringify(
mergeStates(candidates.map(c => JSON.parse(c.value) as Blob))
)

Deletions still need tombstones under either scheme - see last-write-wins.

Sync many keys at once

// One request per provider that batches, instead of one per key.
const pairs = await store.multiGet(['profile', 'settings', 'library'])
await store.multiSet(dirtyEntries)

On CloudKit this is one round trip instead of N - and one rate-limit budget instead of N chances to be throttled, which is how a "sync everything on launch" screen ends up showing ERR_RATE_LIMITED to users with a lot of data.

Drain the queue without wiring it yourself

const store = createCloudStore({
providers: ['icloudKV', 'googleDrive'],
outboxStorage: mmkvAdapter,
autoFlush: true, // on foreground, and every 60s while open
})

If you already have a NetInfo listener, keep calling flushOutbox() from it too - autoFlush is deliberately not network-aware, so that the package does not force a NetInfo dependency on everyone.

Show a "pending sync" indicator

function SyncBadge () {
const [pending, setPending] = useState(0)

useEffect(() => {
const tick = () => setPending(store.pendingWrites().length)
tick()
const sub = AppState.addEventListener('change', s => {
if (s === 'active')
store.flushOutbox().then(tick).catch(() => undefined)
})
return () => sub.remove()
}, [])

return pending > 0 ? <Badge count={pending} /> : null
}

Requires outboxStorage, otherwise the queue is in-memory and empties on restart. See the outbox.

Handle an Apple ID switch

icloudKV.onAccountChange(({ identityChanged }) => {
if (!identityChanged) return

// A different person is signed in. Anything derived from the previous
// account is now wrong - including any "logged in" state you inferred
// from a value that came out of iCloud.
clearUserScopedCaches()
reloadFromCloud()
})

Worth wiring even if you think you do not sync identity. Apps that store a user id in iCloud and skip this end up silently serving the previous user's data after a device changes hands.

Migrate a user from iCloud to Drive

const { copied } = await store.migrate({ from: 'icloudKV', to: 'googleDrive' })

// Verify before removing anything.
for (const key of copied) {
const there = await googleDrive.getItem(key)
if (there == null) throw new Error(`migration incomplete: ${key}`)
}

migrate copies and leaves the source intact, so a partial failure cannot lose data.

If the user, rather than the developer, chooses where their data lives, see Let the user choose their provider, which wires the same call into a settings picker.

Read legacy keys while writing new ones

When adopting this package over an existing store, read through the old path on a miss and lazily copy forward:

async function getWithLegacy (key: string): Promise<string | null> {
const current = await store.getItem(key)
if (current != null) return current

const legacy = await legacyStore.get(key)
if (legacy == null) return null

// Fire and forget - a failed copy just means we try again next read.
store.setItem(key, legacy).catch(() => undefined)
return legacy
}

Keep the legacy read path for at least one release cycle after adoption, or users who skip a version lose whatever the old store held.

Offline-first writes

const store = createCloudStore({
providers: ['icloudKV', 'googleDrive'],
outbox: true,
outboxStorage: mmkvAdapter,
onError: e => log.warn('queued', e.code),
})

// Always write locally first; the cloud is the copy, not the source of truth.
mmkv.set(KEY, json)
await store.setItem(KEY, json) // queued if offline

// Drain on reconnect.
NetInfo.addEventListener(s => {
if (s.isConnected) store.flushOutbox().catch(() => undefined)
})

Let the user choose their provider

A settings picker: which cloud holds their data, or none. See Choosing a provider for why this is usually worth doing.

import {
cloudKit,
googleDrive,
icloudKV,
createCloudStore,
type CloudProvider,
type ProviderName,
} from 'react-native-cloud-sync'

type Choice = ProviderName | 'off'

const ALL: CloudProvider[] = [icloudKV, cloudKit, googleDrive]

/** Only what actually works on this device - never offer a dead option. */
export async function availableProviders (): Promise<ProviderName[]> {
const checked = await Promise.all(
ALL.map(async p => [p.name, await p.isAvailable()] as const)
)
return checked.filter(([, ok]) => ok).map(([name]) => name)
}

export function buildStore (choice: Choice) {
return createCloudStore({
providers: choice === 'off' ? [] : [choice],
tiering: 'auto',
outboxStorage: mmkvAdapter,
})
}

A store with no providers rejects every write with ERR_NOT_SIGNED_IN - the wrong thing to show someone who turned sync off deliberately, and not queued either, since that code needs user action. Branch before calling rather than letting the store reject:

export async function save (key: string, value: string) {
mmkv.set(key, value) // local first, always
if (choice === 'off') return // sync is off; nothing more to do
await store.setItem(key, value)
}

Turning sync off means "stop copying this to a cloud", not "stop saving my data" - so the local write stays unconditional.

Or: one primary, plus an optional second copy

Instead of a single choice, offer a primary provider plus a tick for mirroring to another - what makes an Apple user's data reachable on Android. See Also back up to Google Drive.

export function buildMirroredStore (primary: ProviderName, alsoDrive: boolean) {
return createCloudStore({
providers: alsoDrive ? [primary, 'googleDrive'] : [primary],
writeMode: alsoDrive ? 'mirror' : 'failover',
tiering: 'auto',
outboxStorage: mmkvAdapter,
})
}

/** Enabling only mirrors FUTURE writes - copy what already exists, once. */
export async function enableDriveMirror (primary: ProviderName) {
const store = buildMirroredStore(primary, true)
await store.migrate({ from: primary, to: 'googleDrive' })
return store
}

Persist the choice locally, and rebuild the store when it changes:

export function setChoice (next: Choice) {
mmkv.set('sync/provider', next)
store = buildStore(next)
}

Switching between providers

migrate copies and leaves the source intact, so bringing the data along cannot lose anything on failure:

export async function switchProvider (from: Choice, to: Choice) {
if (from !== 'off' && to !== 'off') {
const combined = createCloudStore({ providers: [from, to] })
await combined.migrate({ from, to })
}
setChoice(to)
}

Turning it off, and deleting what is stored

Two separate questions, asked separately: "stop backing up" and "delete my backup" are different intentions.

export async function turnOff (previous: Choice, alsoDelete: boolean) {
if (alsoDelete && previous !== 'off')
await deleteEverything(previous)
setChoice('off')
}

/**
* Deletes every key this app ever wrote - enumerated, not hardcoded.
*
* Hardcoding a list is how a "delete my backup" flow ends up removing the two
* keys someone remembered and quietly leaving the rest behind. If the user
* asked you to delete their data, delete it.
*/
async function deleteEverything (name: ProviderName) {
const provider = ALL.find(p => p.name === name)
if (provider == null) return

const keys = await provider.getAllKeys()
const failed: string[] = []

for (const key of keys)
try {
await provider.removeItem(key)
}
catch {
failed.push(key)
}

// Report honestly rather than claiming success.
if (failed.length > 0)
throw new Error(`Could not delete ${failed.length} of ${keys.length} items`)
}

Order matters when the provider needs a session: delete first, then disconnect. Once disconnected, deletes become silent no-ops, so the user is told their data is gone when it is not.

Leftover data after a switch

If someone moves from iCloud to Drive, the iCloud copy is still there. Offer to clean it up at the time, or remember the previous provider so the settings screen can offer it later:

const leftover = mmkv.getString('sync/previousProvider')
if (leftover != null && leftover !== 'off') {
const provider = ALL.find(p => p.name === leftover)
const stale = (await provider?.getAllKeys()) ?? []
if (stale.length > 0) offerToDelete(leftover, stale.length)
}