Skip to content

Commit f435f19

Browse files
committed
Refactor hooks: reducers, memoization, deepEqual
Rework hooks internals to improve stability and performance: replace several hook-local useState usages with reducers (useAsync, useMutation) and memoize returned objects to preserve referential identity. Add internal deepEqual and use it to stabilize option objects (useStableOptions, useSecureStorage) and avoid unnecessary callback recreations; introduce a frozen EMPTY_ITEMS fallback for stable empty lists. Update useSecurityAvailability and other hooks to return memoized state and expose createInitialAsyncState/createInitialVoidState from index. Example and docs updated: remove example/useAsyncAction, switch StorageCard to useSecureOperation with explicit handlers, and clarify per-component-instance caching and import path changes in HOOKS.md.
1 parent 3dc9dfc commit f435f19

13 files changed

Lines changed: 369 additions & 210 deletions

docs/HOOKS.md

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -322,9 +322,9 @@ interface SecurityAvailability {
322322

323323
#### Features
324324

325-
-Results automatically cached
326-
-Single native call per app lifecycle
327-
-Refetch on demand
325+
-Result cached **per component instance** — no native call on re-render
326+
-`refetch()` available to bypass the cache after settings changes
327+
-Previous data preserved on error
328328

329329
#### Example
330330

@@ -469,29 +469,19 @@ function LogoutButton() {
469469
All hooks work independently without any provider. Just import and use them directly in your components:
470470

471471
```tsx
472-
import {
473-
useSecureStorage,
474-
useSecurityAvailability
475-
} from 'react-native-sensitive-info'
472+
import {
473+
useSecureStorage,
474+
useSecurityAvailability,
475+
} from 'react-native-sensitive-info/hooks'
476476

477477
function MyComponent() {
478478
const { items } = useSecureStorage({ service: 'myapp' })
479479
const { data: capabilities } = useSecurityAvailability()
480-
481-
// Results are cached automatically - no duplicate native calls
482-
// even if used in multiple components
483-
}
484-
```
485-
486-
**Automatic caching:**
487-
```tsx
488-
// Component A
489-
const { data: cap1 } = useSecurityAvailability()
490480

491-
// Component B
492-
const { data: cap2 } = useSecurityAvailability()
493-
494-
// Both get the SAME cached result - only one native call made!
481+
// Each hook instance keeps its own cache. Mounting `useSecurityAvailability`
482+
// in two components issues two native reads (one per instance), but neither
483+
// re-runs across re-renders unless you call `refetch()`.
484+
}
495485
```
496486

497487
---
@@ -577,25 +567,20 @@ const refresh = useSecretItem('refreshToken')
577567
const apiKey = useSecretItem('apiKey')
578568
```
579569

580-
### 6. Share Capabilities Without Context
570+
### 6. Capability Caching Is Per-Instance
581571

582-
Results are cached automatically - no duplicate native calls:
572+
Each `useSecurityAvailability` mount keeps its own cache, so re-renders never trigger a fresh
573+
native call. Multiple components mounting the hook will each issue one read — if you need a
574+
single source of truth, lift the hook into a parent and pass `data` down via props.
583575

584576
```tsx
585-
// ✅ GOOD: Multiple independent queries
586-
function ComponentA() {
587-
const { data: cap1 } = useSecurityAvailability()
588-
// Native call happens
589-
}
590-
591-
function ComponentB() {
592-
const { data: cap2 } = useSecurityAvailability()
593-
// Returns cached result from ComponentA - no new native call!
577+
// ✅ Re-renders are free — first mount caches, subsequent renders reuse the value.
578+
function Capabilities() {
579+
const { data, isLoading, refetch } = useSecurityAvailability()
580+
// Call refetch() after the user changes biometric enrollment in system settings.
594581
}
595582
```
596583

597-
### 7. Optional Context for Deep Trees (if needed)
598-
599584
### 7. Accessing Security Capabilities
600585

601586
Check what security features are available on the device:
@@ -768,8 +753,8 @@ function Component() {
768753
```tsx
769754
import {
770755
useSecret,
771-
useSecurityAvailability
772-
} from 'react-native-sensitive-info'
756+
useSecurityAvailability,
757+
} from 'react-native-sensitive-info/hooks'
773758

774759
function AuthenticationFlow() {
775760
const {

example/src/components/StorageCard.tsx

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import {
88
type SensitiveInfoOptions,
99
setItem,
1010
} from 'react-native-sensitive-info'
11-
import { useSecureStorage } from 'react-native-sensitive-info/hooks'
11+
import {
12+
useSecureOperation,
13+
useSecureStorage,
14+
} from 'react-native-sensitive-info/hooks'
1215
import Button from './Button'
1316
import Field from './Field'
1417
import Section from './Section'
1518
import StatusLine from './StatusLine'
16-
import { useAsyncAction } from './useAsyncAction'
1719

1820
interface StorageCardProps {
1921
readonly readOptions: SensitiveInfoOptions
@@ -62,40 +64,52 @@ const StorageCard = ({ readOptions, writeOptions }: StorageCardProps) => {
6264

6365
const refresh = storage.refreshItems
6466

65-
const save = useAsyncAction(async () => {
66-
if (!trimmedKey) return
67-
await setItem(trimmedKey, value, writeOptions)
68-
await refresh()
69-
setStatus(`Saved “${trimmedKey}”.`)
70-
})
67+
const save = useSecureOperation()
68+
const revealAction = useSecureOperation()
69+
const remove = useSecureOperation()
70+
const clearAll = useSecureOperation()
7171

72-
const revealAction = useAsyncAction(async () => {
73-
if (!trimmedKey) return
74-
// `getItem` is the only read needing `writeOptions` — it triggers the auth
75-
// prompt for biometric-locked entries.
76-
const item = await getItem(trimmedKey, writeOptions)
77-
if (item?.value == null) return setStatus(`No value for “${trimmedKey}”.`)
78-
setReveal({
79-
key: trimmedKey,
80-
value: item.value,
81-
remaining: REVEAL_TTL_SECONDS,
72+
const handleSave = () =>
73+
void save.execute(async () => {
74+
if (!trimmedKey) return
75+
await setItem(trimmedKey, value, writeOptions)
76+
await refresh()
77+
setStatus(`Saved “${trimmedKey}”.`)
8278
})
83-
})
8479

85-
const remove = useAsyncAction(async () => {
86-
if (!trimmedKey) return
87-
await deleteItem(trimmedKey, writeOptions)
88-
await refresh()
89-
setReveal((prev) => (prev?.key === trimmedKey ? null : prev))
90-
setStatus(`Deleted “${trimmedKey}”.`)
91-
})
80+
const handleReveal = () =>
81+
void revealAction.execute(async () => {
82+
if (!trimmedKey) return
83+
// `getItem` is the only read needing `writeOptions` — it triggers the auth
84+
// prompt for biometric-locked entries.
85+
const item = await getItem(trimmedKey, writeOptions)
86+
if (item?.value == null) {
87+
setStatus(`No value for “${trimmedKey}”.`)
88+
return
89+
}
90+
setReveal({
91+
key: trimmedKey,
92+
value: item.value,
93+
remaining: REVEAL_TTL_SECONDS,
94+
})
95+
})
9296

93-
const clearAll = useAsyncAction(async () => {
94-
await clearService(readOptions)
95-
await refresh()
96-
setReveal(null)
97-
setStatus('Service cleared.')
98-
})
97+
const handleRemove = () =>
98+
void remove.execute(async () => {
99+
if (!trimmedKey) return
100+
await deleteItem(trimmedKey, writeOptions)
101+
await refresh()
102+
setReveal((prev) => (prev?.key === trimmedKey ? null : prev))
103+
setStatus(`Deleted “${trimmedKey}”.`)
104+
})
105+
106+
const handleClearAll = () =>
107+
void clearAll.execute(async () => {
108+
await clearService(readOptions)
109+
await refresh()
110+
setReveal(null)
111+
setStatus('Service cleared.')
112+
})
99113

100114
const busy =
101115
save.isPending ||
@@ -136,28 +150,28 @@ const StorageCard = ({ readOptions, writeOptions }: StorageCardProps) => {
136150
<View style={styles.row}>
137151
<Button
138152
label="Save"
139-
onPress={() => void save.run()}
153+
onPress={handleSave}
140154
disabled={!trimmedKey || busy}
141155
isPending={save.isPending}
142156
variant="primary"
143157
/>
144158
<Button
145159
label={revealing ? `Hiding in ${reveal?.remaining}s` : 'Reveal'}
146-
onPress={() => void revealAction.run()}
160+
onPress={handleReveal}
147161
disabled={!exists || busy || revealing}
148162
isPending={revealAction.isPending}
149163
/>
150164
<Button
151165
label="Delete"
152-
onPress={() => void remove.run()}
166+
onPress={handleRemove}
153167
disabled={!exists || busy}
154168
isPending={remove.isPending}
155169
variant="danger"
156170
/>
157171
</View>
158172

159173
<Pressable
160-
onPress={() => void clearAll.run()}
174+
onPress={handleClearAll}
161175
disabled={busy || empty}
162176
style={({ pressed }) => [
163177
styles.clearLink,

example/src/components/useAsyncAction.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.

src/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export {
22
type AsyncState,
33
createHookFailureResult,
44
createHookSuccessResult,
5+
createInitialAsyncState,
6+
createInitialVoidState,
57
HookError,
68
type HookErrorOptions,
79
type HookFailureResult,

src/hooks/internal/deepEqual.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Structural equality used by the hooks layer to keep memoized option references
3+
* stable across renders.
4+
*
5+
* @remarks
6+
* - Compares primitives with `Object.is` (correctly equating `NaN` and distinguishing `+0`/`-0`).
7+
* - Recurses into plain objects and arrays.
8+
* - Treats `{ a: undefined }` and `{}` as **different** (unlike `JSON.stringify`).
9+
* - Compares own enumerable keys only — does not walk prototypes.
10+
* - Returns `false` (rather than recursing) for instances of `Date`, `RegExp`, `Map`, `Set`,
11+
* functions, and any other non-plain object: option payloads are POJOs by contract, and these
12+
* types should never appear there. Guarding against them prevents accidental false positives.
13+
*
14+
* @internal
15+
*/
16+
export default function deepEqual(a: unknown, b: unknown): boolean {
17+
if (Object.is(a, b)) return true
18+
if (a === null || b === null) return false
19+
if (typeof a !== 'object' || typeof b !== 'object') return false
20+
21+
const aIsArray = Array.isArray(a)
22+
const bIsArray = Array.isArray(b)
23+
if (aIsArray !== bIsArray) return false
24+
25+
if (aIsArray) {
26+
const arrA = a as readonly unknown[]
27+
const arrB = b as readonly unknown[]
28+
if (arrA.length !== arrB.length) return false
29+
for (let i = 0; i < arrA.length; i++) {
30+
if (!deepEqual(arrA[i], arrB[i])) return false
31+
}
32+
return true
33+
}
34+
35+
if (!isPlainObject(a) || !isPlainObject(b)) return false
36+
37+
const keysA = Object.keys(a)
38+
const keysB = Object.keys(b)
39+
if (keysA.length !== keysB.length) return false
40+
41+
for (const key of keysA) {
42+
if (!Object.hasOwn(b, key)) return false
43+
if (!deepEqual(a[key], b[key])) return false
44+
}
45+
return true
46+
}
47+
48+
function isPlainObject(value: object): value is Record<string, unknown> {
49+
const proto = Object.getPrototypeOf(value)
50+
return proto === Object.prototype || proto === null
51+
}

0 commit comments

Comments
 (0)