Synchronous Memory, Disk, and Secure storage for React Native, Expo development builds, and web. Nitro Storage is powered by Nitro Modules and JSI, with typed storage items, React hooks, batch operations, event subscriptions, migrations, biometric secure values, MMKV migration helpers, and configurable web backends.
Use it for startup state, preferences, feature flags, local auth state, secure tokens, biometric-protected values, optimistic writes, app migrations, and state-library persistence where a synchronous API is the right fit. Use a database or server-state cache instead for relational queries, large collections, pagination, conflict resolution, or remote synchronization.
- Install
- Expo Config
- Quick Start
- Typed Storage Items
- Item Ergonomics
- Set Items
- Groups And Lifecycle
- Legacy Key Migration And Secure Resilience
- React Hooks
- Storage Scopes
- Secure Storage
- Batch Operations
- Events And Observability
- Migrations And Transactions
- Web Backends
- Testing
- Platform Support
- Documentation
- Troubleshooting
- Development
- License
bun add react-native-nitro-storage react-native-nitro-modulesPeer dependencies:
| Package | Version |
|---|---|
react |
>=18.2.0 |
react-native |
>=0.75.0 |
react-native-nitro-modules |
>=0.36.4 <0.37.0 |
Nitro peer requirement: react-native-nitro-modules >=0.36.4 <0.37.0.
Validated example baseline: Expo SDK 57, React Native 0.86.2, React 19.2.3, and Nitro Modules 0.36.4.
For Expo development builds:
bunx expo install react-native-nitro-storage react-native-nitro-modules
bunx expo prebuildExpo Go cannot load Nitro native modules. Use an Expo development build or a bare React Native app.
Add the config plugin before prebuilding native iOS and Android projects:
{
"expo": {
"plugins": [
[
"react-native-nitro-storage",
{
"faceIDPermission": "Allow $(PRODUCT_NAME) to unlock secure storage.",
"addBiometricPermissions": false,
"configureAndroidBackup": true
}
]
]
}
}| Option | Default | What it does |
|---|---|---|
faceIDPermission |
Built-in Face ID message | Sets NSFaceIDUsageDescription. |
addBiometricPermissions |
false |
Adds Android biometric and fingerprint permissions. |
configureAndroidBackup |
true |
Writes Android backup rules that exclude secure storage files. |
Android adapter initialization is owned by the package through an Android
manifest initializer, so apps should not edit MainApplication to call
AndroidStorageAdapter.init(this). Set configureAndroidBackup: false only
when your app maintains equivalent backup and device-transfer exclusions for
Nitro Storage secure files.
import {
StorageScope,
createStorageItem,
storage,
} from "react-native-nitro-storage";
const themeItem = createStorageItem<"light" | "dark">({
key: "theme",
namespace: "settings",
scope: StorageScope.Disk,
defaultValue: "light",
});
themeItem.set("dark");
const theme = themeItem.get();
const raw = storage.getString("settings:theme", StorageScope.Disk);createStorageItem<T>() is the recommended API for application code. It keeps
serialization, validation, default values, TTL, namespace, access-control, and
React hook types attached to the key.
type Preferences = {
theme: "system" | "light" | "dark";
compactMode: boolean;
};
const preferencesItem = createStorageItem<Preferences>({
key: "preferences",
namespace: "settings",
scope: StorageScope.Disk,
defaultValue: { theme: "system", compactMode: false },
validate: (value): value is Preferences =>
typeof value === "object" && value !== null && "theme" in value,
});
preferencesItem.set((previous) => ({
...previous,
compactMode: !previous.compactMode,
}));
const snapshot = preferencesItem.getWithVersion();
const didWrite = preferencesItem.setIfVersion(snapshot.version, {
...snapshot.value,
theme: "dark",
});The package ships its own TypeScript types, so editors and AI tools catch
mistakes before they reach the runtime. It exports StorageItem,
StorageItemConfig, StorageSetter, StorageActions, VersionedValue,
StorageBatchSetItem, StorageClearOptions, StorageKeyRef, SetItemConfig,
SetStorageItem, plus web backend, event, secure-metadata, and capability types.
merge, reset, and setOrDelete cover the most common object-state edits
without re-reading or hand-writing compare-and-swap loops. Scoped factories
(memoryItem, diskItem, secureItem) drop the repeated scope field.
import { diskItem, memoryItem } from "react-native-nitro-storage";
const config = diskItem<{ theme: "light" | "dark"; compact: boolean }>({
key: "config",
defaultValue: { theme: "light", compact: false },
});
config.merge({ compact: true }); // shallow object update
config.reset(); // back to the default value
const loginMethod = memoryItem<string | null>({
key: "loginMethod",
defaultValue: null,
});
loginMethod.setOrDelete(maybeMethod); // null/undefined deletes, value setscreateSetItem() models set-membership state (seen ids, dismissed prompts)
without hand-rolling Record<string, true> helpers. Adding an existing member
or deleting an absent one is a no-op, so subscribers do not re-render.
import { createSetItem, StorageScope } from "react-native-nitro-storage";
const dismissedTips = createSetItem({
key: "dismissedTips",
scope: StorageScope.Disk,
});
dismissedTips.add("welcome");
dismissedTips.has("welcome"); // true
dismissedTips.toggle("welcome"); // false (removed)
dismissedTips.values(); // string[]Tag items with a group to clear related state in one call, or keep specific
keys while wiping the rest of a scope. This replaces manual snapshot-and-restore
logout flows.
import { secureItem, storage, StorageScope } from "react-native-nitro-storage";
const accessToken = secureItem<string>({
key: "accessToken",
defaultValue: "",
group: "session",
});
// Wipe everything tied to the session.
storage.clearGroup("session");
// Wipe Disk but keep a few opt-in preferences.
storage.clear(StorageScope.Disk, {
except: [apiEnvironmentItem, "onboardingComplete"],
});renameFrom migrates an old key to a new one on first read and deletes the
legacy entry. Secure items can fall back to the last cached value when the
keychain is locked instead of throwing.
import {
secureItem,
createSecureAuthStorage,
} from "react-native-nitro-storage";
const accessToken = secureItem<string>({
key: "accessToken",
namespace: "auth",
defaultValue: "",
renameFrom: "authToken", // copied + cleaned up on first read
fallbackToCacheOnReadError: true,
onReadError: (error) => reportSecureReadError(error),
});
const auth = createSecureAuthStorage(
{
accessToken: { renameFrom: "authToken" },
refreshToken: { renameFrom: "refreshToken" },
},
{ namespace: "auth", group: "session", fallbackToCacheOnReadError: true },
);import { Switch } from "react-native";
import { useSetStorage, useStorage } from "react-native-nitro-storage";
export function ThemeToggle() {
const [theme] = useStorage(themeItem);
const setTheme = useSetStorage(themeItem);
return (
<Switch
value={theme === "dark"}
onValueChange={(enabled) => setTheme(enabled ? "dark" : "light")}
/>
);
}Use useStorageSelector() when a component needs a derived value instead of the
whole stored object.
import { useStorageSelector } from "react-native-nitro-storage";
const [compactMode] = useStorageSelector(
preferencesItem,
(preferences) => preferences.compactMode,
);useStorage also returns a render-stable actions object as a third element,
and useStorageValue / useStorageActions split read and write concerns.
import {
useStorage,
useStorageActions,
useStorageValue,
} from "react-native-nitro-storage";
const [config, setConfig, actions] = useStorage(configItem);
actions.merge({ compact: true });
actions.reset();
const theme = useStorageValue(themeItem); // read-only, no setter
const tokenActions = useStorageActions(tokenItem); // { set, merge, reset, remove, setOrDelete }| Scope | Backing store | Use it for |
|---|---|---|
StorageScope.Memory |
In-process memory | Session-only state, fast counters, and render-time caches. |
StorageScope.Disk |
UserDefaults on iOS, SharedPreferences on Android, web | Preferences, feature flags, onboarding state, and non-secret persisted data. |
StorageScope.Secure |
Keychain on iOS, Android Keystore-backed preferences | Refresh tokens, credentials, API tokens, and biometric-protected values. |
import {
AccessControl,
BiometricLevel,
StorageScope,
createSecureAuthStorage,
createStorageItem,
isKeychainLockedError,
storage,
} from "react-native-nitro-storage";
const refreshToken = createStorageItem<string>({
key: "refreshToken",
namespace: "auth",
scope: StorageScope.Secure,
defaultValue: "",
accessControl: AccessControl.AfterFirstUnlockThisDeviceOnly,
});
const recoveryCode = createStorageItem<string>({
key: "recoveryCode",
namespace: "auth",
scope: StorageScope.Secure,
defaultValue: "",
biometric: true,
biometricLevel: BiometricLevel.BiometryOrPasscode,
});
const auth = createSecureAuthStorage({
accessToken: { ttlMs: 15 * 60_000 },
refreshToken: {
accessControl: AccessControl.AfterFirstUnlockThisDeviceOnly,
},
});
try {
recoveryCode.get();
} catch (error) {
if (isKeychainLockedError(error)) {
storage.getSecurityCapabilities();
}
}Secure scope uses iOS Keychain and Android Keystore-backed
EncryptedSharedPreferences. Keep secure values small, do not log them, and avoid
exporting secure values unless you are intentionally doing a short-lived
in-memory migration. storage.export(StorageScope.Secure) throws unless you
explicitly opt into { includeSecureValues: true }.
On Android 11 and newer, BiometricLevel.BiometryOnly and
BiometricLevel.BiometryOrPasscode use separate Keystore policies. Android 10
and older support BiometryOrPasscode; BiometryOnly throws
biometric_unavailable because those releases cannot safely enforce the
biometric-only distinction. Secure existence, discovery, and cleanup operations
can also throw when a protected store is locked or its key is invalidated. Catch
those failures and use isKeychainLockedError() when authentication-aware retry
behavior is appropriate.
getBatch() preserves tuple value types, so IDEs infer each result from the
matching item. setBatch() validates every item/value pair independently,
including heterogeneous batches.
import { getBatch, removeBatch, setBatch } from "react-native-nitro-storage";
const localeItem = createStorageItem({
key: "locale",
namespace: "settings",
scope: StorageScope.Disk,
defaultValue: "en-US",
});
const [theme, locale] = getBatch(
[themeItem, localeItem] as const,
StorageScope.Disk,
);
setBatch(
[
{ item: themeItem, value: "dark" },
{ item: localeItem, value: "en-US" },
],
StorageScope.Disk,
);
removeBatch([themeItem, localeItem], StorageScope.Disk);const unsubscribe = storage.subscribeNamespace("settings", (event) => {
console.log(event.key, event.operation, event.source);
});
storage.setEventObserver((event) => {
console.log(event.type, event.scope);
});
storage.setMetricsObserver((event) => {
console.log(event.operation, event.durationMs);
});
const metrics = storage.getMetricsSnapshot();
storage.resetMetrics();
unsubscribe();Secure event observer values are redacted by default. Pass
{ redactSecureValues: false } only in trusted debug tooling where raw values
are safe to inspect.
TTL expiry emits a dedicated "expire" change event. Use
storage.subscribeExpired() to react to keys that lapse on read.
const unsubscribeExpired = storage.subscribeExpired(
StorageScope.Disk,
(event) => {
console.log("expired", event.key);
},
);storage.findDuplicateKeys() and storage.getRegisteredKeys() help audit
accidental (scope, key) collisions; call them once at startup in development.
import {
migrateFromMMKV,
migrateToLatest,
registerMigration,
runTransaction,
} from "react-native-nitro-storage";
registerMigration(2, ({ getRaw, setRaw, removeRaw }) => {
const oldTheme = getRaw("legacyTheme");
if (oldTheme) {
setRaw("settings:theme", oldTheme);
removeRaw("legacyTheme");
}
});
migrateToLatest(StorageScope.Disk);
runTransaction(StorageScope.Disk, (tx) => {
tx.setItem(themeItem, "dark");
tx.setItem(localeItem, "en-US");
});
migrateFromMMKV(mmkvInstance, themeItem);runTransaction(scope, callback) rolls back every write made through the tx
context if the callback throws.
import {
setWebDiskStorageBackend,
setWebSecureStorageBackend,
} from "react-native-nitro-storage";
import { createIndexedDBBackend } from "react-native-nitro-storage/indexeddb-backend";
const backend = await createIndexedDBBackend({
dbName: "app-storage",
storeName: "kv",
});
setWebDiskStorageBackend(backend);
setWebSecureStorageBackend(backend);Browser storage cannot provide iOS Keychain or Android Keystore guarantees. Web Secure scope is only as strong as the backend you configure.
The react-native-nitro-storage/testing entrypoint is a faithful in-memory
implementation of the full public surface, so unit tests and Storybook run
without native modules. Mock the package with it, or use it directly.
import {
createNitroStorageMock,
resetNitroStorageMock,
} from "react-native-nitro-storage/testing";
// Jest: swap the real module for the in-memory implementation.
jest.mock("react-native-nitro-storage", () =>
require("react-native-nitro-storage/testing"),
);
beforeEach(() => {
resetNitroStorageMock();
});
// Or build an isolated instance per test file.
const { storage, memoryItem } = createNitroStorageMock();| Platform | Status |
|---|---|
| iOS | Memory, Disk, and Keychain-backed Secure storage. |
| Android | Memory, Disk, and Keystore-backed Secure storage. |
| Web | Memory plus configurable Disk and Secure backends. |
| Expo | Development builds with the config plugin. |
| Topic | File |
|---|---|
| API reference | docs/api-reference.md |
| React hooks | docs/react-hooks.md |
| Secure storage | docs/secure-storage.md |
| Web backends | docs/web-backends.md |
| Batch, transactions, and migrations | docs/batch-transactions-migrations.md |
| MMKV migration | docs/mmkv-migration.md |
| Recipes | docs/recipes.md |
| Benchmarks | docs/benchmarks.md |
| Security policy | SECURITY.md |
- Expo Go error: build a development client; Expo Go cannot load Nitro modules.
- Android not initialized: rebuild the native app after installing or upgrading the package so the Android manifest initializer is merged.
- Secure values fail after Android restore: keep
configureAndroidBackup: trueor provide equivalent backup exclusions. - Biometric prompt does not appear: set
biometric: trueon the item and add native biometric permissions when your app needs them. - Web secure storage is unavailable: configure a secure backend before using Secure scope on web.
- TypeScript cannot infer
getBatch()tuple values: pass readonly tuples withas const, or keep batch items in aconsttuple.
bun install
bun run check
bun run test:cpp:asan
bun run test:cpp:ubsan
bun run test:cpp:tsan
bun run release:preflight
bun run example:android
bun run example:iosRun native example builds before release when changing plugin, native, Nitro, secure storage, or packaging files. The package release path also validates package contents and dry-run publish behavior.