Skip to content

rust-dd/react-native-scc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

react-native-scc

npm license platforms

Ultra-low-latency, persistent key-value storage for React Native and Expo. The core is written in Rust on top of scc (a lock-free concurrent hash map), and it reaches JavaScript through Nitro Modules, so a call from JS to Rust costs nanoseconds, not microseconds.

This project is also a statement of intent: bringing Rust closer to React Native. A Rust core behind Nitro Modules ships as an ordinary npm package — no toolchain for consumers, no compromise on performance — and this library is the proof that the pattern can go head-to-head with established C++ storage libraries.

The design goal is simple: every read is a RAM lookup, every write is durable, and the disk never sits on your hot path. Writes update the in-memory map synchronously and stream to a write-ahead log on a background thread; a hard kill can cost you the last few milliseconds of writes, but committed data is never corrupted.

  • All reads from RAM — the persistent store reads exactly as fast as a pure in-memory one
  • Durable by default — write-ahead log with group commits, atomic snapshot compaction, CRC-protected recovery
  • Sync and async APIs — sync for the hot path, *Async variants on Nitro's thread pool for anything that must not block the JS thread
  • Batch operationssetMany/getMany cross the bridge once for a whole record set
  • Transactions + namespaces — crash-atomic sync commits, prefix helpers, and scoped KV views
  • Encryption at rest — opt-in ChaCha20-Poly1305 per instance, snapshot and WAL both encrypted
  • TTL + eviction — per-key expiry with a background sweeper, optional maxEntries cap
  • Native change events — listeners, selectors, and hooks react to writes made through any handle of the same store
  • React hooksuseKVString, useKVNumber, useKVBoolean, useKVBuffer, useKVJSON
  • State-manager adapters — zustand persist, jotai atomWithKV, redux-persist engine as subpath exports
  • Zero-config persistence — storage lands in the platform app-data directory (iOS: Application Support, Android: filesDir)
  • Multiple independent stores — each id gets its own file pair, WAL thread, and durability settings
  • iOS + Android, Expo dev builds + bare React Native

Benchmarks

JS-visible synchronous API latency vs react-native-mmkv 4.3.2, measured by the example app in an iOS 26.3.1 simulator Release build. The table shows the range of per-launch medians from two independent launches. Each launch runs four balanced AB/BA trials with a physically recreated SCC store, cleared MMKV store, and verified 103-key seed per trial. Scalar cases run 100k iterations; 100-key cases run 1k iterations and report per-key latency. SCC uses its default relaxed WAL and performs two sequential, untimed flush barriers after every SCC sample; the second waits behind post-flush writer maintenance, so compaction cannot overlap the following MMKV sample.

Case (lower is better) SCC MMKV
setMany, 100 × 16 B, per key 209–213 ns 308 ns
getMany, 100 × 16 B, per key 104–118 ns 167–188 ns
Set string, 64 B 358–438 ns 530–581 ns
Get string, 64 B 164–184 ns 176–202 ns
Set string, 16 B 292–365 ns 291–316 ns
Get string, 16 B 146–160 ns 166–177 ns
Set number 221–270 ns 225–232 ns
Get number 111 ns 129–132 ns
Get missing key 111–113 ns 126–133 ns

These simulator launches are not a universal device claim. The write cases measure API-return latency, not fsync; call flush() when you need an explicit durability barrier. The setMany row compares one SCC bridge call with 100 independent MMKV scalar calls; it is a throughput comparison, not a transaction or crash-atomicity claim. Run the benchmark yourself with the in-app Run benchmark button; the app persists all raw samples and methodology metadata. For automated Release runs, set EXPO_PUBLIC_SCC_AUTORUN_BENCHMARK=1 before building.

Install

npm install react-native-scc-storage react-native-nitro-modules

Expo

The config plugin declares Expo SDK 57 or newer as an optional peer.

{ "plugins": ["react-native-scc-storage"] }
npx expo prebuild
npx expo run:ios
npx expo run:android

Expo Go is not supported (native code) — use a dev build.

Bare React Native

cd ios && pod install

Prebuilt Rust static libraries ship with the package. If they are missing (e.g. a source checkout), the build scripts compile them automatically — that path requires a Rust toolchain with the iOS/Android targets installed.

Quick start

import { createKV } from 'react-native-scc-storage'

const kv = createKV() // persistent, id 'default'

// sync — the hot path
kv.set('user.name', 'Ada')
kv.set('user.score', 42.5)
kv.set('user.premium', true)
kv.setJSON('user.prefs', { theme: 'dark' })

kv.getString('user.name')                    // 'Ada'
kv.getNumber('user.score')                   // 42.5
kv.getJSON<{ theme: string }>('user.prefs')  // { theme: 'dark' }

// async — same store, Nitro thread pool
await kv.setAsync('big.blob', someArrayBuffer)
const blob = await kv.getBufferAsync('big.blob')
await kv.flushAsync() // durability barrier

// batch — one bridge crossing
kv.setMany({ a: '1', b: '2', c: '3' })
kv.getMany(['a', 'b', 'missing']) // ['1', '2', undefined]

Instances

Every id is an independent store with its own files (<id>.snap + <id>.wal), its own background writer, and its own settings. Opening the same id twice returns the same underlying store.

const settings = createKV({ id: 'settings' })
const vault    = createKV({ id: 'vault', durability: 'strict' })   // fsync every commit
const cache    = createKV({ id: 'cache' })                          // relaxed (default): ~1s fsync
const ui       = createKV({ id: 'ui', persistence: 'none' })        // pure in-memory, no files

Options: id, path (override the storage directory), persistence: 'wal' | 'none', durability: 'relaxed' | 'strict', recreate (wipe on open), encryptionKey (see below), maxEntries, and ttlSweepIntervalMs.

Encryption at rest

const vault = createKV({ id: 'vault', encryptionKey: 'my-secret-passphrase' })

Everything the instance writes to disk — snapshot and write-ahead log alike — is encrypted with ChaCha20-Poly1305; the 256-bit cipher key is derived from the passphrase with SHA-256. Opening an encrypted store with a wrong key (or without one) fails without touching the files, and opening a plaintext store with a key fails too, so a configuration mistake can never silently corrupt or rewrite data. Store the passphrase in the platform keystore (Keychain / Android Keystore) — the library deliberately does not manage key storage for you.

TTL

kv.set('session.token', token, { ttlMs: 15 * 60 * 1000 })
kv.setJSON('cache.profile', profile, { ttlMs: 60_000 })

Expired keys read as missing immediately (get, contains, getAllKeys all agree), and a background sweeper physically reclaims them — also from disk — on the instance's WAL thread (sweep interval: 30 s). TTL persists across restarts: a key set with a 1-hour TTL is still gone after a kill + relaunch past its deadline.

Eviction

const cache = createKV({
  id: 'cache',
  maxEntries: 10_000,
  ttlSweepIntervalMs: 5_000,
})

When the store outgrows maxEntries, the sweeper evicts expired keys first, then arbitrary live keys until it fits. Eviction order is unspecified (not LRU) — use it as a safety cap, not a cache policy.

Transactions

const next = kv.transaction((tx) => {
  const current = tx.getNumber('counter') ?? 0
  tx.set('counter', current + 1)
  tx.setJSON('counter.meta', { updatedAt: Date.now() })
  return current + 1
})

Transactions are synchronous and crash-atomic for recovery: the callback stages writes in JS and sees its own staged values, then commits them as one WAL record, so replay after a crash applies either all staged writes or none. Concurrent readers do not get multi-key isolation while the in-memory commit is being applied. Async callbacks are rejected so the library never holds transactional state across an await.

Prefixes and namespaces

const user = kv.namespace('user:123')

user.set('name', 'Ada')              // stores user:123:name
user.setJSON('prefs', { theme: 'dark' })
user.getAllKeys()                    // ['name', 'prefs']
user.clearAll()                      // deletes only user:123:* keys

kv.getKeysByPrefix('user:123:')      // full keys
kv.deleteByPrefix('cache:')

Namespaces are lightweight JS views over the same underlying store. They do not create extra files or WAL threads.

Hooks

import { useKVNumber } from 'react-native-scc-storage'

function Counter() {
  const [count, setCount] = useKVNumber('counter')
  return <Button title={`${count ?? 0}`} onPress={() => setCount((count ?? 0) + 1)} />
}

Each hook returns [value, setValue]; calling setValue(undefined) deletes the key. Hooks re-render on any write to the key, including writes made through other KV objects opened with the same id.

const theme = useKVSelector<{ theme?: string }, string | undefined>(
  'settings',
  (settings) => settings?.theme
)

Change listener

The listener fires for every mutation of the underlying store, from any handle. key is null after clearAll ("everything changed"). Delivery is asynchronous on the JS thread.

const sub = kv.addOnValueChangedListener((key) => {
  console.log(key === null ? 'store cleared' : `changed: ${key}`)
})
sub.remove()

Selectors sit on top of the same listener and only fire when the selected value changes:

const sub = kv.observeJSON(
  'settings',
  (settings: { theme?: string } | undefined) => settings?.theme,
  (theme) => console.log('theme changed', theme)
)

Adapters

One package, three subpath exports. zustand and jotai are optional peer dependencies — install only what you use.

zustand

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { sccStateStorage } from 'react-native-scc-storage/zustand'

const useStore = create(
  persist((set) => ({ bears: 0 }), {
    name: 'bears',
    storage: createJSONStorage(() => sccStateStorage()),
  })
)

The storage is synchronous, so zustand hydrates without an async gap — no loading flicker, no onRehydrateStorage dance.

jotai

import { atomWithKV } from 'react-native-scc-storage/jotai'

const counterAtom = atomWithKV('counter', 0)

Reads synchronously on init (getOnInit) and reacts to writes made outside jotai — including other KV handles — via the native change listener.

redux-persist

import { createSccStorage } from 'react-native-scc-storage/redux'

const persistedReducer = persistReducer(
  { key: 'root', storage: createSccStorage() },
  rootReducer
)

API

sync async returns
set(key, value) setAsync void — value: string | number | boolean | ArrayBuffer
setJSON(key, value) setJSONAsync void
setMany(entries) setManyAsync voidRecord<string, string>
transaction(callback) callback return value
namespace(prefix) scoped KV view
getKeysByPrefix(prefix) string[]
deleteByPrefix(prefix) number
observeJSON(key, selector, listener) KVSubscription
getString(key) getStringAsync string | undefined
getNumber(key) getNumberAsync number | undefined
getBoolean(key) getBooleanAsync boolean | undefined
getBuffer(key) getBufferAsync ArrayBuffer | undefined
getJSON<T>(key) getJSONAsync T | undefined
getMany(keys) getManyAsync (string | undefined)[]
contains(key) containsAsync boolean
delete(key) deleteAsync boolean
getAllKeys() getAllKeysAsync string[]
clearAll() clearAllAsync number sync, void async
flush() flushAsync void — blocks until fsynced
size number
close() void

Reading a key that holds a different type returns undefined (matching react-native-mmkv). Numbers are IEEE-754 doubles, i.e. exactly JS number semantics.

Durability model

Writes update the in-memory map synchronously, then stream to a write-ahead log on a dedicated background thread. The writer batches records into group commits (8 ms or 128 KiB, whichever comes first). With durability: 'relaxed' (default) the log is fsynced about once per second; with 'strict' every group commit is fsynced. flush() / flushAsync() is the explicit barrier: it returns only after everything written so far is on disk.

On restart the store recovers from snapshot + WAL replay. Every record carries a CRC32; a torn tail from a hard kill is truncated and recovery continues — committed data is never lost or corrupted. When the WAL outgrows max(4 MiB, 2 × snapshot size), the background writer compacts it into an atomically replaced snapshot, keeping recovery files bounded without moving disk I/O onto the JS thread.

Architecture

TypeScript (KV class, hooks, adapters)
  └─ Nitro Modules (JSI, sync calls, zero-copy where possible)
      └─ C++ HybridObjects
          └─ C FFI (cbindgen, panic-safe boundary)
              └─ Rust core: scc::HashMap (lock-free reads) + WAL writer thread

The Rust core is an independent crate (crates/kv-core) with its own test suite: crash-recovery tests that truncate the WAL at every byte offset, multi-threaded stress tests racing writers against compaction, and criterion benchmarks. The C ABI layer (crates/kv-ffi) wraps every entry point in catch_unwind, so a Rust panic can never unwind across the language boundary.

Development

npm install
npm run specs            # tsc + nitrogen codegen
npm test                 # jest (KV + hooks + adapters against a mock native layer)
cargo test --workspace   # Rust core + FFI suites
cargo bench -p kv-core   # criterion benchmarks
npm run rust:build       # cross-compile static libs for iOS + Android

The example app under example/ runs a full on-device self-test (sync/async round-trips, persistence across launches, cross-handle change events) and the MMKV comparison benchmark with live charts.

License

MIT

About

Rust-powered ultra-fast persistent key-value storage for React Native and Expo, via Nitro Modules.

Topics

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors