iCloud key-value store, CloudKit, iCloud Drive and Google Drive behind one API - iOS, Android, web, both architectures.
Documentation Β· Comparison Β· API Β· Platform Notes
- βοΈ Four providers behind one API:
NSUbiquitousKeyValueStore(Apple's small key-value iCloud store), CloudKit records, iCloud Drive documents and Google DriveappDataFolder. Use them directly or through one facade. - π Auto-sync between iOS and Android in both directions:
mirrorwrites to iCloud and Drive together,resolvereads back whichever copy is newest, so either platform can be where the user started. - π CloudKit reaches Android and web too, via CloudKit Web Services, against the same private database your iOS app uses.
- π
icloudDocumentswrites into the user's own iCloud Drive, so the files show up in Files.app. - π¨ Every failure is a typed rejection (
ERR_NOT_SIGNED_IN,ERR_QUOTA_EXCEEDED,ERR_RATE_LIMITEDwithretryAfterMs, ...);nullmeans only "key doesn't exist". - π€ All five
CKAccountStatusvalues surface as-is, plusonAccountChangewithidentityChanged, which drops the previous account's caches and queued writes. - π
onRemoteChangefires on every provider and the facade, including Google Drive via its change cursor. - π¦ Small values go to the key-value store, larger ones to a
CKRecordfield, binary to aCKAssetor a resumable Drive upload. The size check picks the target. - π Retryable failures queue into a durable outbox: backoff honours retry hints, auto-drains on foreground, bounded, never overwrites a newer write.
- π§Ί
multiGet/multiSet/multiRemove/clearbatch for real, one request per provider. - πͺ React hooks from
/hooks:useCloudItem,useCloudItems,useCloudCollection,useAccountStatus,usePendingWrites. They drop stale responses and neversetStateafter unmount. - π
cloudKitEncrypteduses CloudKit's ownencryptedValues, so only ciphertext leaves the device; every other provider has acodecseam for your own cipher. - π§ͺ An in-memory provider with fault injection, plus the native mock, both exported (
/testing,/jest-mock), so every failure path is testable in Jest. - βοΈ React Native 0.71 through 0.86+, old and new architecture, with the
#ifdefbridge for the legacy one.
Cloud storage in React Native is fragmented into single-provider wrappers repeating the same defects: two ship a setItem that reports a failed write as a success (one checks the wrong error variable, the other discards the result entirely), and a third flattens five iCloud account states into one boolean.
A catch { return null } makes "not signed in", "offline", "out of storage" and "no such key" indistinguishable. The app can't tell the user anything useful, or decide whether to retry.
This library was built error contract first, providers second.
This package is a wrapper. When something behaves unexpectedly, the answer is usually in Apple's or Google's docs.
Apple
NSUbiquitousKeyValueStore |
The iCloud key-value store; 1 MB / 1024-key limits |
CloudKit Β· CKDatabase Β· CKRecord |
Records in the user's private database |
CKAsset Β· CKRecordZone |
Binary assets and custom zones |
CKAccountStatus |
The five account states this package surfaces verbatim |
| CloudKit Web Services | The REST API behind the Android and web paths |
| Authentication Β· Data size limits Β· Error codes | Worth reading before shipping CloudKit on Android |
| iCloud entitlements | The keys the config plugin writes |
| CloudKit Console | Where containers, schemas, API tokens and the sign-in callback live |
The appDataFolder |
The hidden per-app folder this package stores into |
Drive files resource |
The REST endpoints behind the provider |
| Drive API scopes | Why the scope is drive.appdata |
| this | kuatsu/ cloud-storage |
icloud-kit | expo-cloudkit | okwasniewski/ icloud-storage |
cloudkit- storage |
|
|---|---|---|---|---|---|---|
| iCloud key-value store | β | β | β | - | β | - |
| CloudKit records | β | - | β | β | - | β |
| Google Drive | β | β | - | - | - | - |
| iOS | β | β | β | β | β | β |
| Android | β | β | - | β [1] | - | - |
| Web | β | partial [2] | - | - | - | - |
| CloudKit on Android/web | β | - | - | β [1] | - | - |
| New Architecture | β | β | β | β | β | - |
| Legacy Architecture | β | β | β [3] | β [3] | β [3] | β |
| Typed error codes | β | - | partial | β | - | - |
| 5-value account status | β | boolean | boolean | β | - | - |
| Identity-change event | β | - | β | - | - | |
| Remote-change event | β | β | - | n/a | β [5] | β |
| Offline write queue | β | - | - | β | - | - |
| Size tiering | β | - | - | - | - | - |
| Binary / assets | β [9] | β | β [6] | β | - | - |
| User-visible iCloud Drive files | β | - | - | - | β | - |
| Native end-to-end encryption | β | - | - | - | - | - |
| Batch operations | β | - | - | - | - | - |
| React hooks | β | β | - | - | - | - |
| Encryption seam | β | - | - | - | - | - |
| Exported test harness | β | - | - | - | - | |
| Mac Catalyst | β | - | - | - | - | |
| Actively maintained | β | β | β | β [8] | β [5] | β |
[1] expo-cloudkit's README: Android throws CloudKitNotSupportedError on every call.
[2] Google Drive support is text-based only.
[3] Via the Expo Modules API; pulls in expo-modules-core.
[4] Fires before JS binds the emitter, crashing with std::bad_function_call (SIGABRT); hit three times.
[5] One version ever shipped; the change-listener PR has sat open since February.
[6] Field type is string | number | null, so it can't hold binary data.
[7] A mock factory exists, but isn't exported or documented.
[8] No commits since April; the last four releases shipped with Swift that didn't compile.
[9] CKAsset on Apple platforms; CloudKit Web Services' upload-token protocol on Android/web, capped at 15 MB by CloudKit. Use googleDriveFiles for anything bigger; it chunks and resumes the same way.
| Choosing a provider | Which one, what it costs, why to let the user pick |
| iCloud key-value store | Small settings, zero friction, Apple only |
| CloudKit | Records, zones, assets, and the Android/web path |
| iCloud Drive | Files in the user's own Drive, visible in Files.app |
| Google Drive | The always-on cross-platform backend |
| The store facade | Tiering, outbox, migration, fallthrough |
| Error handling | The typed contract |
| Encryption | What's encrypted for you, and how to add your own |
| React hooks | Binding cloud state to components |
| Recipes | Backup/restore, migration, offline-first |
| Testing | Fault injection without a device |
| API reference | Every export |
| Platform notes | Entitlements, architectures, build config |
| Troubleshooting | Common problems and what they usually mean |
| Minimum | |
|---|---|
| React Native | 0.71 |
| iOS | 15.1 |
| Node | 20 |
Both architectures supported; RN 0.82 removed Legacy Architecture, so that half only matters on 0.81 and below.
| iOS / macOS | Android | Web | |
|---|---|---|---|
icloudKV |
native | - | - |
cloudKit |
native | REST | REST |
cloudKitEncrypted |
native | - | - |
icloudDocuments |
native | - | - |
googleDrive |
REST | REST | REST |
An unavailable provider rejects with ERR_UNSUPPORTED_PLATFORM instead of silently doing nothing. Choosing a provider covers the trade-offs.
npx expo install react-native-cloud-syncAdd the config plugin, then rebuild:
{
"expo": {
"plugins": [
["react-native-cloud-sync", {
"containerIdentifier": "iCloud.com.your.app"
}]
]
}
}Bare React Native and manual ios/ entitlement keys: see Installation.
No sign-in and no UI; it uses the device's existing account.
import { icloudKV } from 'react-native-cloud-sync'
await icloudKV.setItem('settings/theme', 'dark')
const theme = await icloudKV.getItem('settings/theme')
// null means the key does not exist. Nothing else returns null.
icloudKV.onRemoteChange(({ keys }) => reload(keys))The same private database from iOS, Android and web.
import { cloudKit, cloudKitAssets } from 'react-native-cloud-sync'
await cloudKit.setItem('playlist', JSON.stringify(tracks))
const raw = await cloudKit.getItem('playlist')
// Anything above the 1 MB record limit goes in as a streamed CKAsset.
await cloudKitAssets.save({ recordName: 'avatar', fieldName: 'image', fileUri })Android/web needs an Apple ID sign-in, and that token lasts up to two weeks, which suits an explicit import rather than background sync. Full guide
CloudKit's own end-to-end encryption: Apple stores ciphertext and holds no key.
import { cloudKitEncrypted } from 'react-native-cloud-sync'
await cloudKitEncrypted.setItem('auth.refreshToken', token)Apple-only: the key lives in the user's iCloud Keychain, so nothing server-side can decrypt it. Cross-platform needs the store's codec seam instead. Full guide
Files the user can open in Files.app.
import { icloudDocuments } from 'react-native-cloud-sync'
await icloudDocuments.save({ fileUri: localPath, name: 'Export 2024.csv' })
// A listed file may be a placeholder with no local bytes. fetch() downloads it.
const path = await icloudDocuments.fetch({ name: 'Export 2024.csv' })Identical behaviour on every platform, no periodic re-auth.
import { configureGoogleDrive, googleDrive } from 'react-native-cloud-sync'
configureGoogleDrive({
getAccessToken: async () => (await GoogleSignin.getTokens()).accessToken,
})
await googleDrive.setItem('playlist.json', JSON.stringify(tracks))import { createCloudStore } from 'react-native-cloud-sync'
const store = createCloudStore({
providers: ['icloudKV', 'googleDrive'], // preference order
writeMode: 'mirror', // write to both providers, not only the preferred one
resolve: resolveByTimestamp('updatedAt'), // read whichever copy is newest
tiering: 'auto', // route by size
outboxStorage: mmkvAdapter, // survive restarts
})
await store.setItem('playlist', json)
await store.flushOutbox() // on reconnectThose two options make sync work in both directions across a mixed fleet: mirror copies to Drive so non-Apple devices can read it; resolve stops an Apple device serving a stale iCloud copy without checking Drive. Full guide
import { useCloudItem } from 'react-native-cloud-sync/hooks'
const { value, setValue, loading, error } = useCloudItem<Settings>(store, 'settings')Re-reads on remote writes, drops stale responses, never calls setState after unmount. Full guide
import { isRetryable, requiresUserAction } from 'react-native-cloud-sync'
try {
await store.setItem('k', 'v')
} catch (e) {
if (requiresUserAction(e)) promptUser(e.code) // signed out, out of storage
else if (isRetryable(e)) scheduleRetry(e.retryAfterMs)
}import { ErrorCode } from 'react-native-cloud-sync'
import { createMemoryProvider } from 'react-native-cloud-sync/testing'
const provider = createMemoryProvider({
faults: { setItem: { code: ErrorCode.QUOTA_EXCEEDED } },
})
provider.emitAccountChange({ status: 'available', identityChanged: true })Signed-out, offline and account-switch paths, all in Jest without a device. Full guide
A playground covering every API, plus a live sync demo for side-by-side recording:
cd example
yarn installDevBuild:ios # or :android
yarn start:webTabs: Sync (shared counter, one-tap mirror mode to iCloud+Drive), iCloud KV, CloudKit, Drive, Files (large-file backup/restore via GoogleDriveFileAdapter), Store, Faults.
Issues and pull requests welcome. Run yarn lint, yarn typecheck and yarn test before opening one.
MIT




