react-native-nitro-storage 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md ADDED
@@ -0,0 +1,26 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Security fixes are shipped for the latest published `0.x` release line.
6
+
7
+ | Version | Supported |
8
+ | ------- | --------- |
9
+ | `0.5.x` | Yes |
10
+ | `<0.5` | No |
11
+
12
+ ## Reporting a Vulnerability
13
+
14
+ Report security issues through GitHub Security Advisories with:
15
+
16
+ - affected package version
17
+ - platform and OS version
18
+ - React Native and `react-native-nitro-modules` versions
19
+ - reproduction steps
20
+ - whether the issue affects Memory, Disk, Secure, biometric storage, web backends, or packaging
21
+
22
+ Do not publish proof-of-concept exploit details until a fix is available.
23
+
24
+ ## Storage Boundary
25
+
26
+ Native Secure scope delegates encryption to platform storage APIs: iOS Keychain and Android Jetpack Security `EncryptedSharedPreferences`. Web Secure scope is API-compatible but defaults to namespaced `localStorage`; use a custom web secure backend when browser-side storage must meet a stricter threat model.
@@ -0,0 +1,217 @@
1
+ # API Reference
2
+
3
+ This page lists the public API surface. For copy-ready workflows, see [recipes.md](recipes.md).
4
+
5
+ ## createStorageItem
6
+
7
+ ```ts
8
+ const item = createStorageItem<T>({
9
+ key: "theme",
10
+ scope: StorageScope.Disk,
11
+ defaultValue: "system",
12
+ });
13
+ ```
14
+
15
+ `StorageItemConfig<T>`:
16
+
17
+ | Field | Type | Purpose |
18
+ | ---------------------- | -------------------------------- | ---------------------------------------------------------------- |
19
+ | `key` | `string` | Storage key. Combined with `namespace` when provided. |
20
+ | `scope` | `StorageScope` | Memory, Disk, or Secure. |
21
+ | `defaultValue` | `T` | Value returned when no stored value exists. |
22
+ | `serialize` | `(value: T) => string` | Custom string encoder. Defaults to primitive/JSON serialization. |
23
+ | `deserialize` | `(value: string) => T` | Custom string decoder. |
24
+ | `validate` | `(value: unknown) => value is T` | Runtime guard for stored data. |
25
+ | `onValidationError` | `(invalidValue: unknown) => T` | Replacement value when validation fails. |
26
+ | `expiration` | `{ ttlMs: number }` | Time-to-live for the value. |
27
+ | `onExpired` | `(key: string) => void` | Called when a read detects TTL expiry. |
28
+ | `readCache` | `boolean` | Cache parsed values in memory. |
29
+ | `coalesceDiskWrites` | `boolean` | Buffer Disk writes until the next flush. |
30
+ | `coalesceSecureWrites` | `boolean` | Buffer Secure writes until the next flush. |
31
+ | `namespace` | `string` | Prefix keys as `namespace:key`. |
32
+ | `biometric` | `boolean` | Store through biometric secure storage. |
33
+ | `biometricLevel` | `BiometricLevel` | Require biometric/passcode or biometric-only access. |
34
+ | `accessControl` | `AccessControl` | Platform secure accessibility setting. |
35
+
36
+ `StorageItem<T>`:
37
+
38
+ | Method | Purpose |
39
+ | ------------------------------ | ----------------------------------------------------------- |
40
+ | `get()` | Return the typed value or the default value. |
41
+ | `getWithVersion()` | Return `{ value, version }` for optimistic writes. |
42
+ | `set(value)` | Store a value. Accepts direct values or updater functions. |
43
+ | `setIfVersion(version, value)` | Store only when the current version still matches. |
44
+ | `delete()` | Remove the key. |
45
+ | `has()` | Check whether the key exists. |
46
+ | `subscribe(callback)` | Subscribe to item changes. Returns an unsubscribe function. |
47
+ | `serialize(value)` | Serialize a value with the item encoder. |
48
+ | `deserialize(value)` | Deserialize a raw string with the item decoder. |
49
+
50
+ ## React Hooks
51
+
52
+ ```ts
53
+ const [value, setValue] = useStorage(item);
54
+ const [selected, setItem] = useStorageSelector(item, selector, isEqual);
55
+ const setOnly = useSetStorage(item);
56
+ ```
57
+
58
+ See [react-hooks.md](react-hooks.md).
59
+
60
+ ## storage
61
+
62
+ `storage` exposes raw and cross-item utilities:
63
+
64
+ | Method | Purpose |
65
+ | ---------------------------------- | ------------------------------------------------------------- |
66
+ | `clear(scope)` | Clear one scope. |
67
+ | `clearAll()` | Clear Memory, Disk, and Secure scopes. |
68
+ | `clearNamespace(namespace, scope)` | Remove keys under `namespace:`. |
69
+ | `clearBiometric()` | Clear biometric Secure entries. |
70
+ | `has(key, scope)` | Check for a raw key. |
71
+ | `getAllKeys(scope)` | List raw keys. |
72
+ | `getKeysByPrefix(prefix, scope)` | List raw keys with a prefix. |
73
+ | `getByPrefix(prefix, scope)` | Read raw string values by prefix. |
74
+ | `getAll(scope)` | Read all raw string values in a scope. |
75
+ | `size(scope)` | Return approximate scope entry count. |
76
+ | `setAccessControl(accessControl)` | Set the default Secure access control level. |
77
+ | `setSecureWritesAsync(enabled)` | Toggle Android secure writes between sync and async modes. |
78
+ | `setDiskWritesAsync(enabled)` | Toggle coalesced Disk write behavior. |
79
+ | `flushDiskWrites()` | Flush pending Disk writes. |
80
+ | `flushSecureWrites()` | Flush pending Secure writes. |
81
+ | `setKeychainAccessGroup(group)` | Configure iOS Keychain access group. |
82
+ | `setMetricsObserver(observer)` | Receive operation timing events. |
83
+ | `getMetricsSnapshot()` | Read aggregated metrics. |
84
+ | `resetMetrics()` | Clear metrics counters. |
85
+ | `getCapabilities()` | Read runtime storage capabilities. |
86
+ | `getSecurityCapabilities()` | Read secure backend capability metadata. |
87
+ | `getSecureMetadata(key)` | Read secure metadata for one key without returning its value. |
88
+ | `getAllSecureMetadata()` | Read secure metadata for all secure keys without values. |
89
+ | `getString(key, scope)` | Read a raw string. |
90
+ | `setString(key, value, scope)` | Write a raw string. |
91
+ | `deleteString(key, scope)` | Remove a raw key. |
92
+ | `import(data, scope)` | Bulk import raw strings. |
93
+
94
+ Raw string APIs bypass item serialization and validation. Prefer `StorageItem<T>` unless you are migrating, importing, or writing a custom integration.
95
+
96
+ ## Batch Operations
97
+
98
+ ```ts
99
+ const values = getBatch([themeItem, localeItem], StorageScope.Disk);
100
+
101
+ setBatch(
102
+ [
103
+ { item: themeItem, value: "dark" },
104
+ { item: localeItem, value: "en-US" },
105
+ ],
106
+ StorageScope.Disk,
107
+ );
108
+
109
+ removeBatch([themeItem, localeItem], StorageScope.Disk);
110
+ ```
111
+
112
+ See [batch-transactions-migrations.md](batch-transactions-migrations.md).
113
+
114
+ ## Transactions
115
+
116
+ ```ts
117
+ runTransaction(StorageScope.Disk, (tx) => {
118
+ const current = tx.getItem(balanceItem);
119
+ tx.setItem(balanceItem, current + 10);
120
+ });
121
+ ```
122
+
123
+ If the callback throws, previously changed keys in that transaction are rolled back synchronously.
124
+
125
+ ## Migrations
126
+
127
+ ```ts
128
+ registerMigration(2, (ctx) => {
129
+ const oldTheme = ctx.getRaw("theme");
130
+ if (oldTheme === "black") {
131
+ ctx.setRaw("theme", "dark");
132
+ }
133
+ });
134
+
135
+ migrateToLatest(StorageScope.Disk);
136
+ ```
137
+
138
+ Migration versions are tracked per scope.
139
+
140
+ ## Secure Auth Storage
141
+
142
+ ```ts
143
+ const auth = createSecureAuthStorage({
144
+ accessToken: { ttlMs: 15 * 60 * 1000 },
145
+ refreshToken: { accessControl: AccessControl.AfterFirstUnlockThisDeviceOnly },
146
+ });
147
+
148
+ auth.accessToken.set("token");
149
+ ```
150
+
151
+ The returned object is a typed record of secure string `StorageItem`s.
152
+
153
+ ## Web Backend APIs
154
+
155
+ ```ts
156
+ setWebDiskStorageBackend(backend);
157
+ getWebDiskStorageBackend();
158
+ setWebSecureStorageBackend(backend);
159
+ getWebSecureStorageBackend();
160
+ await flushWebStorageBackends();
161
+ ```
162
+
163
+ See [web-backends.md](web-backends.md).
164
+
165
+ ## Enums
166
+
167
+ ```ts
168
+ enum StorageScope {
169
+ Memory = 0,
170
+ Disk = 1,
171
+ Secure = 2,
172
+ }
173
+
174
+ enum BiometricLevel {
175
+ None = 0,
176
+ BiometryOrPasscode = 1,
177
+ BiometryOnly = 2,
178
+ }
179
+ ```
180
+
181
+ `AccessControl` values:
182
+
183
+ - `WhenUnlocked`
184
+ - `AfterFirstUnlock`
185
+ - `WhenPasscodeSetThisDeviceOnly`
186
+ - `WhenUnlockedThisDeviceOnly`
187
+ - `AfterFirstUnlockThisDeviceOnly`
188
+
189
+ ## Exported Types
190
+
191
+ Common public types:
192
+
193
+ - `Storage`
194
+ - `Validator<T>`
195
+ - `ExpirationConfig`
196
+ - `StorageItem<T>`
197
+ - `StorageItemConfig<T>`
198
+ - `StorageBatchSetItem<T>`
199
+ - `StorageVersion`
200
+ - `VersionedValue<T>`
201
+ - `StorageMetricsEvent`
202
+ - `StorageMetricsObserver`
203
+ - `StorageMetricSummary`
204
+ - `MigrationContext`
205
+ - `Migration`
206
+ - `TransactionContext`
207
+ - `SecureAuthStorageConfig<K>`
208
+ - `SecurityCapabilities`
209
+ - `SecureStorageMetadata`
210
+ - `StorageErrorCode`
211
+ - `WebStorageBackend`
212
+ - `WebDiskStorageBackend`
213
+ - `WebSecureStorageBackend`
214
+ - `WebStorageChangeEvent`
215
+ - `WebStorageScope`
216
+
217
+ The IndexedDB subpath exports `createIndexedDBBackend()` and `IndexedDBBackendOptions`.
@@ -0,0 +1,186 @@
1
+ # Batch, Transactions, and Migrations
2
+
3
+ Use these APIs when a workflow touches several keys or needs a controlled upgrade path.
4
+
5
+ ## Batch Reads
6
+
7
+ ```ts
8
+ import {
9
+ createStorageItem,
10
+ getBatch,
11
+ StorageScope,
12
+ } from "react-native-nitro-storage";
13
+
14
+ const themeItem = createStorageItem({
15
+ key: "theme",
16
+ scope: StorageScope.Disk,
17
+ defaultValue: "system",
18
+ });
19
+
20
+ const localeItem = createStorageItem({
21
+ key: "locale",
22
+ scope: StorageScope.Disk,
23
+ defaultValue: "en-US",
24
+ });
25
+
26
+ const [theme, locale] = getBatch([themeItem, localeItem], StorageScope.Disk);
27
+ ```
28
+
29
+ All items in a batch must use the same scope.
30
+
31
+ ## Batch Writes
32
+
33
+ ```ts
34
+ import { setBatch, StorageScope } from "react-native-nitro-storage";
35
+
36
+ setBatch(
37
+ [
38
+ { item: themeItem, value: "dark" },
39
+ { item: localeItem, value: "pt-BR" },
40
+ ],
41
+ StorageScope.Disk,
42
+ );
43
+ ```
44
+
45
+ Memory-scope batch writes are two-phase: all values are written first, then listeners are notified. Items with validation or TTL fall back to per-item writes so those rules still run.
46
+
47
+ ## Batch Removes
48
+
49
+ ```ts
50
+ import { removeBatch, StorageScope } from "react-native-nitro-storage";
51
+
52
+ removeBatch([themeItem, localeItem], StorageScope.Disk);
53
+ ```
54
+
55
+ ## Raw Import
56
+
57
+ `storage.import(data, scope)` writes raw strings. It does not serialize values.
58
+
59
+ ```ts
60
+ import { storage, StorageScope } from "react-native-nitro-storage";
61
+
62
+ storage.import(
63
+ {
64
+ "flags:newOnboarding": "true",
65
+ "flags:paywall": "control",
66
+ },
67
+ StorageScope.Disk,
68
+ );
69
+ ```
70
+
71
+ For Memory scope, import is atomic: all keys are written before listeners fire. For Disk and Secure, import delegates to native or web batch paths.
72
+
73
+ ## Transactions
74
+
75
+ Use `runTransaction(scope, callback)` when several raw or item writes should roll back together if the callback throws.
76
+
77
+ ```ts
78
+ import { runTransaction, StorageScope } from "react-native-nitro-storage";
79
+
80
+ const fromBalanceItem = createStorageItem({
81
+ key: "account:from",
82
+ scope: StorageScope.Disk,
83
+ defaultValue: 100,
84
+ });
85
+
86
+ const toBalanceItem = createStorageItem({
87
+ key: "account:to",
88
+ scope: StorageScope.Disk,
89
+ defaultValue: 0,
90
+ });
91
+
92
+ runTransaction(StorageScope.Disk, (tx) => {
93
+ const from = tx.getItem(fromBalanceItem);
94
+
95
+ if (from < 25) {
96
+ throw new Error("Insufficient balance");
97
+ }
98
+
99
+ tx.setItem(fromBalanceItem, from - 25);
100
+ tx.setItem(toBalanceItem, tx.getItem(toBalanceItem) + 25);
101
+ });
102
+ ```
103
+
104
+ Transaction context methods:
105
+
106
+ - `getRaw(key)`
107
+ - `setRaw(key, value)`
108
+ - `removeRaw(key)`
109
+ - `getItem(item)`
110
+ - `setItem(item, value)`
111
+ - `removeItem(item)`
112
+
113
+ If the callback throws, Nitro Storage restores the keys it changed during that transaction.
114
+
115
+ ## Migrations
116
+
117
+ Register migrations with monotonically increasing versions, then migrate a scope to the latest known version.
118
+
119
+ ```ts
120
+ import {
121
+ migrateToLatest,
122
+ registerMigration,
123
+ StorageScope,
124
+ } from "react-native-nitro-storage";
125
+
126
+ registerMigration(1, (ctx) => {
127
+ const oldValue = ctx.getRaw("theme");
128
+ if (oldValue === "black") {
129
+ ctx.setRaw("theme", "dark");
130
+ }
131
+ });
132
+
133
+ registerMigration(2, (ctx) => {
134
+ const token = ctx.getRaw("token");
135
+ if (token) {
136
+ ctx.setRaw("auth:accessToken", token);
137
+ ctx.removeRaw("token");
138
+ }
139
+ });
140
+
141
+ migrateToLatest(StorageScope.Disk);
142
+ ```
143
+
144
+ Migration context methods work with raw strings. Use item serializers manually when migrating structured data.
145
+
146
+ ```ts
147
+ registerMigration(3, (ctx) => {
148
+ const raw = ctx.getRaw("settings");
149
+ if (!raw) {
150
+ return;
151
+ }
152
+
153
+ const settings = JSON.parse(raw) as { mode?: string };
154
+ ctx.setRaw(
155
+ "settings",
156
+ JSON.stringify({
157
+ ...settings,
158
+ theme: settings.mode ?? "system",
159
+ }),
160
+ );
161
+ });
162
+ ```
163
+
164
+ ## Prefix Queries and Cleanup
165
+
166
+ ```ts
167
+ const keys = storage.getKeysByPrefix("tenant:42:", StorageScope.Disk);
168
+ const values = storage.getByPrefix("tenant:42:", StorageScope.Disk);
169
+
170
+ storage.clearNamespace("tenant:42", StorageScope.Disk);
171
+ ```
172
+
173
+ Use namespaces for multi-account or tenant-specific state so cleanup is predictable.
174
+
175
+ ## Optimistic Versioned Writes
176
+
177
+ ```ts
178
+ const current = themeItem.getWithVersion();
179
+
180
+ const didWrite = themeItem.setIfVersion(
181
+ current.version,
182
+ current.value === "dark" ? "light" : "dark",
183
+ );
184
+ ```
185
+
186
+ `setIfVersion()` returns `false` if another write changed the item after `getWithVersion()`.
@@ -0,0 +1,37 @@
1
+ # Benchmarks
2
+
3
+ Benchmarks are release checks, not product promises. Use them to catch regressions on the local machine and CI image used by this repo.
4
+
5
+ Run:
6
+
7
+ ```sh
8
+ bun run benchmark -- --filter=react-native-nitro-storage
9
+ ```
10
+
11
+ The benchmark script checks representative synchronous read/write paths and fails when results drift beyond the configured threshold.
12
+
13
+ ## Interpreting Results
14
+
15
+ - Compare results on the same machine and Node/Bun version.
16
+ - Treat large deltas as a prompt to inspect recent storage-runtime, serialization, native bridge, or cache changes.
17
+ - Do not compare web backend numbers against native secure storage numbers; they measure different systems.
18
+ - Secure storage performance depends on platform state, device lock state, biometric prompts, and Keystore/Keychain behavior.
19
+
20
+ ## Release Checklist
21
+
22
+ Before publishing:
23
+
24
+ ```sh
25
+ bun run lint -- --filter=react-native-nitro-storage
26
+ bun run format:check -- --filter=react-native-nitro-storage
27
+ bun run typecheck -- --filter=react-native-nitro-storage
28
+ bun run test:types -- --filter=react-native-nitro-storage
29
+ bun run test -- --filter=react-native-nitro-storage
30
+ bun run test:cpp -- --filter=react-native-nitro-storage
31
+ bun run build -- --filter=react-native-nitro-storage
32
+ bun run benchmark -- --filter=react-native-nitro-storage
33
+ bun run --cwd packages/react-native-nitro-storage check:pack
34
+ npm publish --dry-run
35
+ ```
36
+
37
+ Keep the dry-publish output in the release notes when validating a version locally.
@@ -0,0 +1,80 @@
1
+ # MMKV Migration
2
+
3
+ Use `migrateFromMMKV(mmkv, item, deleteAfterMigration?)` when an app already stores values with `react-native-mmkv` and you want to move one key at a time into Nitro Storage.
4
+
5
+ The helper reads in this order:
6
+
7
+ 1. `mmkv.getString(key)`
8
+ 2. `mmkv.getNumber(key)`
9
+ 3. `mmkv.getBoolean(key)`
10
+
11
+ It writes through `item.set()`, so custom serialization, validation, TTL behavior, and listeners remain active.
12
+
13
+ ## Basic Migration
14
+
15
+ ```ts
16
+ import {
17
+ createStorageItem,
18
+ migrateFromMMKV,
19
+ StorageScope,
20
+ } from "react-native-nitro-storage";
21
+ import { MMKV } from "react-native-mmkv";
22
+
23
+ const mmkv = new MMKV();
24
+
25
+ const usernameItem = createStorageItem({
26
+ key: "username",
27
+ scope: StorageScope.Disk,
28
+ defaultValue: "",
29
+ });
30
+
31
+ const migrated = migrateFromMMKV(mmkv, usernameItem, true);
32
+ ```
33
+
34
+ `migrated` is `true` when a value was found and written. The third argument deletes the MMKV key after a successful write.
35
+
36
+ ## Type Conversion
37
+
38
+ MMKV numbers and booleans are converted through the target item's serializer.
39
+
40
+ ```ts
41
+ const launchCountItem = createStorageItem({
42
+ key: "launchCount",
43
+ scope: StorageScope.Disk,
44
+ defaultValue: 0,
45
+ });
46
+
47
+ migrateFromMMKV(mmkv, launchCountItem, true);
48
+ ```
49
+
50
+ ## Custom Serialized Values
51
+
52
+ ```ts
53
+ type Settings = {
54
+ compactMode: boolean;
55
+ };
56
+
57
+ const settingsItem = createStorageItem<Settings>({
58
+ key: "settings",
59
+ scope: StorageScope.Disk,
60
+ defaultValue: { compactMode: false },
61
+ serialize: JSON.stringify,
62
+ deserialize: JSON.parse,
63
+ validate: (value): value is Settings =>
64
+ typeof value === "object" &&
65
+ value !== null &&
66
+ "compactMode" in value &&
67
+ typeof value.compactMode === "boolean",
68
+ });
69
+
70
+ migrateFromMMKV(mmkv, settingsItem, true);
71
+ ```
72
+
73
+ ## Migration Strategy
74
+
75
+ - Migrate stable keys first: preferences, feature flags, and local settings.
76
+ - Keep secure credentials in Secure scope instead of moving them to Disk scope.
77
+ - Run migration once during startup, before components read the target item.
78
+ - Delete the MMKV key only after you have shipped and observed the migration path.
79
+
80
+ For larger data-shape upgrades, use the versioned migration APIs in [batch-transactions-migrations.md](batch-transactions-migrations.md).
@@ -0,0 +1,113 @@
1
+ # React Hooks
2
+
3
+ Nitro Storage hooks connect a `StorageItem<T>` to React with `useSyncExternalStore`. No provider is needed, and the same item can also be used outside React.
4
+
5
+ Keep storage items at module scope. Creating items during render creates new subscriptions and breaks cache reuse.
6
+
7
+ ## useStorage
8
+
9
+ Use `useStorage(item)` when a component both reads and writes the full value.
10
+
11
+ ```tsx
12
+ import {
13
+ createStorageItem,
14
+ StorageScope,
15
+ useStorage,
16
+ } from "react-native-nitro-storage";
17
+
18
+ const themeItem = createStorageItem({
19
+ key: "theme",
20
+ scope: StorageScope.Disk,
21
+ defaultValue: "system",
22
+ });
23
+
24
+ export function ThemeToggle() {
25
+ const [theme, setTheme] = useStorage(themeItem);
26
+
27
+ return (
28
+ <Button
29
+ title={theme}
30
+ onPress={() => setTheme(theme === "dark" ? "light" : "dark")}
31
+ />
32
+ );
33
+ }
34
+ ```
35
+
36
+ Updater functions read the current stored value first:
37
+
38
+ ```ts
39
+ const countItem = createStorageItem({
40
+ key: "count",
41
+ scope: StorageScope.Memory,
42
+ defaultValue: 0,
43
+ });
44
+
45
+ const [, setCount] = useStorage(countItem);
46
+ setCount((current) => current + 1);
47
+ ```
48
+
49
+ Direct `set(value)` writes do not read the current value first.
50
+
51
+ ## useStorageSelector
52
+
53
+ Use `useStorageSelector(item, selector, isEqual?)` when the stored value is larger than what the component renders.
54
+
55
+ ```tsx
56
+ type Profile = {
57
+ name: string;
58
+ email: string;
59
+ plan: "free" | "pro";
60
+ };
61
+
62
+ const profileItem = createStorageItem<Profile>({
63
+ key: "profile",
64
+ scope: StorageScope.Disk,
65
+ defaultValue: { name: "", email: "", plan: "free" },
66
+ });
67
+
68
+ export function PlanBadge() {
69
+ const [plan] = useStorageSelector(profileItem, (profile) => profile.plan);
70
+ return <Text>{plan}</Text>;
71
+ }
72
+ ```
73
+
74
+ Pass a custom equality function when the selector returns an object:
75
+
76
+ ```ts
77
+ const [contact] = useStorageSelector(
78
+ profileItem,
79
+ (profile) => ({ name: profile.name, email: profile.email }),
80
+ (prev, next) => prev.name === next.name && prev.email === next.email,
81
+ );
82
+ ```
83
+
84
+ ## useSetStorage
85
+
86
+ Use `useSetStorage(item)` for controls that write without rendering from the value.
87
+
88
+ ```tsx
89
+ const dismissItem = createStorageItem({
90
+ key: "welcomeDismissed",
91
+ scope: StorageScope.Disk,
92
+ defaultValue: false,
93
+ });
94
+
95
+ export function DismissWelcomeButton() {
96
+ const setDismissed = useSetStorage(dismissItem);
97
+ return <Button title="Dismiss" onPress={() => setDismissed(true)} />;
98
+ }
99
+ ```
100
+
101
+ ## Subscribe Outside React
102
+
103
+ Every item has a low-level subscription API:
104
+
105
+ ```ts
106
+ const unsubscribe = themeItem.subscribe(() => {
107
+ analytics.track("theme_changed", { theme: themeItem.get() });
108
+ });
109
+
110
+ unsubscribe();
111
+ ```
112
+
113
+ Subscriptions fire after item writes and after TTL expiry is detected during a read.