This document covers the React hooks API for react-native-sensitive-info, designed with modern React best practices including automatic cleanup, memory leak prevention, and performance optimization.
- Quick Start
- Core Hooks
- Best Practices
- Performance Considerations
- Error Handling
- Migration Guide
- Examples
npm install react-native-sensitive-info
# or
yarn add react-native-sensitive-infoimport { useSecretItem, useSecureStorage } from 'react-native-sensitive-info/hooks'
function MyComponent() {
// Read a single secret
const { data, isLoading, error } = useSecretItem('apiToken')
// Manage all secrets in a service
const { items, saveSecret, removeSecret } = useSecureStorage({
service: 'myapp'
})
if (isLoading) return <Text>Loading...</Text>
if (error) return <Text>Error: {error.message}</Text>
return <Text>{data?.value}</Text>
}Fetches and manages a single secure storage item with automatic loading and error states.
function useSecretItem(
key: string,
options?: SensitiveInfoOptions & {
includeValue?: boolean
skip?: boolean
}
): AsyncState<SensitiveInfoItem> & {
refetch: () => Promise<void>
}
interface AsyncState<TData> {
data: TData | null
error: HookError | null
isLoading: boolean
isPending: boolean
}- ✅ Automatic request cancellation on unmount
- ✅ Memory leak prevention via cleanup
- ✅ Conditional loading with
skipparameter - ✅ Manual refetch support
- ✅ Type-safe error handling
function TokenViewer() {
const { data, isLoading, error, refetch } = useSecretItem('refreshToken', {
service: 'auth',
accessControl: 'secureEnclaveBiometry',
authenticationPrompt: {
title: 'Authenticate',
description: 'Required to access your token'
}
})
if (isLoading) return <ActivityIndicator />
if (error) return <Text>Failed to load token: {error.message}</Text>
if (!data) return <Text>No token found</Text>
return (
<View>
<Text>{data.value}</Text>
<Button title="Refresh" onPress={refetch} />
</View>
)
}A convenience hook that combines reading and writing a single secret. Includes save and delete operations.
function useSecret(
key: string,
options?: SensitiveInfoOptions & { includeValue?: boolean }
): AsyncState<SensitiveInfoItem> & {
saveSecret: (value: string) => Promise<{ success: boolean; error?: HookError }>
deleteSecret: () => Promise<{ success: boolean; error?: HookError }>
refetch: () => Promise<void>
}- ✅ Read and write in a single hook
- ✅ Automatic state synchronization after mutations
- ✅ Optimized for single secret management
function AuthTokenManager() {
const {
data: token,
isLoading,
saveSecret,
deleteSecret,
refetch
} = useSecret('authToken', { service: 'myapp' })
const handleLogout = async () => {
const { success, error } = await deleteSecret()
if (success) {
navigateTo('Login')
} else {
showError(error?.message)
}
}
const handleRefreshToken = async (newToken: string) => {
const { success } = await saveSecret(newToken)
if (success) {
showNotification('Token updated')
await refetch()
}
}
return (
<View>
{token && <Text>Token exists: {token.metadata.securityLevel}</Text>}
<Button title="Update Token" onPress={() => handleRefreshToken('new')} />
<Button title="Logout" onPress={handleLogout} />
</View>
)
}Lightweight hook for checking if a secret exists without fetching its value.
function useHasSecret(
key: string,
options?: SensitiveInfoOptions & { skip?: boolean }
): AsyncState<boolean> & {
refetch: () => Promise<void>
}- ✅ Efficient existence checks
- ✅ Minimal performance overhead
- ✅ No decryption needed
function ConditionalContent() {
const { data: tokenExists, isLoading } = useHasSecret('apiToken')
if (isLoading) return <Text>Checking...</Text>
return tokenExists ? <AuthenticatedContent /> : <LoginForm />
}Manages all secrets in a service with full CRUD operations and automatic state synchronization.
function useSecureStorage(
options?: SensitiveInfoOptions & {
includeValues?: boolean
skip?: boolean
}
): {
items: SensitiveInfoItem[]
isLoading: boolean
error: HookError | null
saveSecret: (key: string, value: string) => Promise<{ success: boolean; error?: HookError }>
removeSecret: (key: string) => Promise<{ success: boolean; error?: HookError }>
clearAll: () => Promise<{ success: boolean; error?: HookError }>
refreshItems: () => Promise<void>
}- ✅ Full CRUD operations
- ✅ Optimistic updates for delete
- ✅ Automatic list refresh after save/delete
- ✅ Selective value inclusion
- ✅ Service-wide operations
function SecureStorageManager() {
const {
items,
isLoading,
error,
saveSecret,
removeSecret,
clearAll,
refreshItems
} = useSecureStorage({
service: 'credentials',
includeValues: false // Don't fetch values initially
})
const handleAddSecret = async () => {
const { success, error: err } = await saveSecret('apiKey', 'secret-value')
if (!success) {
showError(err?.message)
}
}
const handleRemoveSecret = async (key: string) => {
const { success } = await removeSecret(key)
if (success) {
showNotification(`Deleted ${key}`)
}
}
const handleClearAll = async () => {
if (confirm('Delete all secrets?')) {
const { success } = await clearAll()
if (success) {
showNotification('All secrets cleared')
}
}
}
if (isLoading) return <ActivityIndicator />
if (error) return <Text>Error: {error.message}</Text>
return (
<View>
<FlatList
data={items}
renderItem={({ item }) => (
<SecretListItem
item={item}
onDelete={() => handleRemoveSecret(item.key)}
/>
)}
keyExtractor={item => item.key}
/>
<Button title="Add Secret" onPress={handleAddSecret} />
<Button title="Clear All" onPress={handleClearAll} />
<Button title="Refresh" onPress={refreshItems} />
</View>
)
}Fetches and caches device security capabilities (Secure Enclave, StrongBox, Biometry, etc.).
function useSecurityAvailability(
options?: UseSecurityAvailabilityOptions
): AsyncState<SecurityAvailability> & {
refetch: () => Promise<void>
}
interface UseSecurityAvailabilityOptions {
/** Auto-refresh when the app returns to `active`. Debounced ~500 ms. */
readonly refreshOnForeground?: boolean
}
interface SecurityAvailability {
readonly secureEnclave: boolean
readonly strongBox: boolean
readonly biometry: boolean
readonly biometryStatus:
| 'available'
| 'notEnrolled'
| 'notAvailable'
| 'lockedOut'
| 'unknown'
readonly deviceCredential: boolean
}- ✅ Result cached per component instance — no native call on re-render
- ✅
refetch()available to bypass the cache after settings changes - ✅ Previous data preserved on error
- ✅
biometryStatusdistinguishes no hardware from hardware present but unenrolled — drive an “Enroll Face ID” CTA off'notEnrolled'instead of hiding the toggle - ✅
refreshOnForegroundsubscribes toAppStateand refetches when the user returns from system settings (off by default)
function AccessControlSelector() {
const { data: capabilities, isLoading } = useSecurityAvailability({
refreshOnForeground: true,
})
if (isLoading) return <Text>Detecting capabilities...</Text>
if (capabilities?.biometryStatus === 'notEnrolled') {
return (
<Pressable onPress={() => Linking.openSettings()}>
<Text>Set up Face ID / fingerprint →</Text>
</Pressable>
)
}
return (
<View>
{capabilities?.secureEnclave && <Text>✓ Secure Enclave available</Text>}
{capabilities?.biometry && <Text>✓ Biometry available</Text>}
{capabilities?.deviceCredential && <Text>✓ Device credential available</Text>}
</View>
)
}Use useBiometryStatusWatcher for transition-only callbacks (fires once per real BiometryStatus change, never on every render):
import { useBiometryStatusWatcher } from 'react-native-sensitive-info/hooks'
useBiometryStatusWatcher((next, previous) => {
if (previous === 'notEnrolled' && next === 'available') {
showToast('Face ID is ready.')
}
})Pair the snapshot with canUseAccessControlSync so the toggle reflects whether the policy you intend to use will actually succeed:
import { canUseAccessControlSync } from 'react-native-sensitive-info'
const { data: caps } = useSecurityAvailability()
const canEnableSecureEnclave = caps
? canUseAccessControlSync('secureEnclaveBiometry', caps)
: falseManage versioned master-key rotation for a given service. Calls rotateKeys() under the hood and keeps the active version, last rotation result, and loading/error state.
function useKeyRotation(options?: UseKeyRotationOptions): {
lastResult: RotationResult | null
error: HookError | null
isRotating: boolean
rotate: () => Promise<HookMutationResult>
readVersion: () => Promise<number | null>
}
interface UseKeyRotationOptions extends SensitiveInfoOptions {
reEncryptEagerly?: boolean // default: false (lazy rotation)
}
interface RotationResult {
previousVersion: number
newVersion: number
reEncryptedCount: number
}import { useKeyRotation } from 'react-native-sensitive-info/hooks'
function RotationButton() {
const { rotate, isRotating, lastResult, error } = useKeyRotation({
service: 'auth',
})
return (
<View>
<Button
title={isRotating ? 'Rotating…' : 'Rotate master key'}
onPress={rotate}
disabled={isRotating}
/>
{lastResult && (
<Text>
v{lastResult.previousVersion} → v{lastResult.newVersion}
</Text>
)}
{error && <Text>{error.message}</Text>}
</View>
)
}Note: Defaults to lazy rotation — entries are re-encrypted opportunistically when they are next read. Pass
reEncryptEagerly: trueto walk every entry up front.
One-time operation hook for non-reactive operations (e.g., bulk operations, logout).
function useSecureOperation(): VoidAsyncState & {
execute: (operation: () => Promise<void>) => Promise<void>
}
interface VoidAsyncState {
error: HookError | null
isLoading: boolean
isPending: boolean
}- ✅ Flexible operation execution
- ✅ Loading state management
- ✅ Error handling
function LogoutButton() {
const { execute, isLoading, error } = useSecureOperation()
const handleLogout = async () => {
await execute(async () => {
// Clear all app credentials
await clearService({ service: 'auth' })
await clearService({ service: 'cache' })
// Navigate to login
navigateTo('Login')
})
}
if (error) return <Text>Logout failed: {error.message}</Text>
return (
<Button
title="Logout"
onPress={handleLogout}
disabled={isLoading}
/>
)
}All hooks work independently without any provider. Just import and use them directly in your components:
import {
useSecureStorage,
useSecurityAvailability,
} from 'react-native-sensitive-info/hooks'
function MyComponent() {
const { items } = useSecureStorage({ service: 'myapp' })
const { data: capabilities } = useSecurityAvailability()
// Each hook instance keeps its own cache. Mounting `useSecurityAvailability`
// in two components issues two native reads (one per instance), but neither
// re-runs across re-renders unless you call `refetch()`.
}All hooks automatically clean up resources on unmount:
// ✅ GOOD: Automatic cleanup
function Component() {
const { data, isLoading } = useSecretItem('token')
// Cleanup happens automatically on unmount
}Use the skip parameter to conditionally skip fetches:
// ✅ GOOD: Conditional fetching
function Component() {
const isAuthenticated = useIsAuthenticated()
const { data } = useSecretItem('token', { skip: !isAuthenticated })
// Won't fetch until user is authenticated
}Stabilize options objects to prevent unnecessary API calls:
// ✅ GOOD: Memoized options
const options = useMemo(() => ({
service: 'myapp',
accessControl: 'secureEnclaveBiometry'
}), []) // Empty deps - only create once
const { data } = useSecretItem('token', options)
// ❌ BAD: New object every render
const { data } = useSecretItem('token', {
service: 'myapp',
accessControl: 'secureEnclaveBiometry'
})Always check error states and provide user feedback:
// ✅ GOOD: Proper error handling
function Component() {
const { data, error, isLoading } = useSecretItem('token')
if (isLoading) return <ActivityIndicator />
if (error) return <ErrorBoundary error={error} />
if (!data) return <Text>No data found</Text>
return <Text>{data.value}</Text>
}Use useSecureStorage instead of multiple useSecretItem calls:
// ✅ GOOD: Single hook for multiple items
function Component() {
const { items } = useSecureStorage({ service: 'auth' })
// Access all items
}
// ❌ AVOID: Multiple hook instances
const token = useSecretItem('token')
const refresh = useSecretItem('refreshToken')
const apiKey = useSecretItem('apiKey')Each useSecurityAvailability mount keeps its own cache, so re-renders never trigger a fresh
native call. Multiple components mounting the hook will each issue one read — if you need a
single source of truth, lift the hook into a parent and pass data down via props.
// ✅ Re-renders are free — first mount caches, subsequent renders reuse the value.
function Capabilities() {
const { data, isLoading, refetch } = useSecurityAvailability()
// Call refetch() after the user changes biometric enrollment in system settings.
}Check what security features are available on the device:
// ✅ GOOD: Direct hook usage
function SecurityStatus() {
const { data: capabilities, isLoading } = useSecurityAvailability()
if (isLoading) return <ActivityIndicator />
return (
<View>
<Text>Biometric: {capabilities?.isBiometricEnabled ? '✓' : '✗'}</Text>
<Text>Strong Box: {capabilities?.isStrongBoxAvailable ? '✓' : '✗'}</Text>
</View>
)
}Use refetch() when you need to sync state with native storage:
// ✅ GOOD: Manual refetch after external updates
const { data, refetch } = useSecretItem('token')
const handleExternalUpdate = async () => {
await externallyUpdateToken()
await refetch() // Sync with native state
}All hooks automatically cancel in-flight requests on unmount:
// If component unmounts while fetching, request is cancelled
const { data, isLoading } = useSecretItem('token')useSecurityAvailability caches results to avoid repeated native calls:
const cap1 = useSecurityAvailability() // Calls native
const cap2 = useSecurityAvailability() // Uses cacheUse includeValues: false when you only need metadata:
// ✅ GOOD: Only fetch metadata
const { items } = useSecureStorage({ includeValues: false })
// ❌ AVOID: Unnecessary decryption
const { items } = useSecureStorage({ includeValues: true })Delete operations update UI immediately:
const { removeSecret } = useSecureStorage()
// UI updates immediately, native call happens in background
await removeSecret('token') // Optimistic deleteThe HookError class wraps errors with context:
class HookError extends Error {
constructor(
message: string,
public readonly originalError?: unknown
) {}
}function Component() {
const { error, data } = useSecretItem('token')
if (error) {
// Log original error for debugging
console.error('Hook error:', error.originalError)
// Show user-friendly message
return <Text>Failed to load token: {error.message}</Text>
}
return <Text>{data?.value}</Text>
}function Component() {
const [token, setToken] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let mounted = true
const fetchToken = async () => {
try {
const item = await getItem('token')
if (mounted) setToken(item)
} catch (err) {
if (mounted) setError(err)
} finally {
if (mounted) setLoading(false)
}
}
fetchToken()
return () => {
mounted = false
}
}, [])
return loading ? <Text>Loading</Text> : <Text>{token?.value}</Text>
}// ✅ MUCH CLEANER
function Component() {
const { data: token, isLoading, error } = useSecretItem('token')
return isLoading ? <Text>Loading</Text> : <Text>{token?.value}</Text>
}import {
useSecret,
useSecurityAvailability,
} from 'react-native-sensitive-info/hooks'
function AuthenticationFlow() {
const {
data: token,
isLoading: tokenLoading,
saveSecret,
deleteSecret
} = useSecret('authToken', {
service: 'myapp',
accessControl: 'secureEnclaveBiometry'
})
const { data: capabilities } = useSecurityAvailability()
const handleLogin = async (credentials) => {
const response = await login(credentials)
const { success } = await saveSecret(response.token)
if (success) {
navigateTo('Home')
}
}
const handleLogout = async () => {
const { success } = await deleteSecret()
if (success) {
navigateTo('Login')
}
}
return token ? <HomeScreen onLogout={handleLogout} /> : <LoginForm />
}function BiometricAuth() {
const { data: capabilities } = useSecurityAvailability()
const { data: storedToken } = useSecretItem('biometricToken')
const canUseBiometry = capabilities?.biometry ?? false
if (!canUseBiometry) {
return <Text>Biometry not available</Text>
}
return (
<Button
title="Authenticate with Biometry"
onPress={async () => {
const item = await getItem('biometricToken', {
authenticationPrompt: {
title: 'Authenticate',
description: 'Use your biometry to unlock'
}
})
if (item) {
authorizeUser(item.value)
}
}}
/>
)
}function CredentialsManager() {
const authCredentials = useSecureStorage({
service: 'auth',
includeValues: false
})
const apiKeys = useSecureStorage({
service: 'api',
includeValues: false
})
return (
<View>
<Section title="Auth Credentials">
{authCredentials.items.map(item => (
<CredentialItem
key={item.key}
item={item}
onDelete={() => authCredentials.removeSecret(item.key)}
/>
))}
</Section>
<Section title="API Keys">
{apiKeys.items.map(item => (
<CredentialItem
key={item.key}
item={item}
onDelete={() => apiKeys.removeSecret(item.key)}
/>
))}
</Section>
</View>
)
}All hooks are fully typed with TypeScript:
import type {
AsyncState,
HookError,
VoidAsyncState
} from 'react-native-sensitive-info/hooks'
const { data, error, isLoading }: AsyncState<SensitiveInfoItem> = useSecretItem('token')
const hookError: HookError = error
const originalError: unknown = error?.originalErrorSolution: Check for errors in the console. Ensure proper options are passed.
Solution: Hooks automatically clean up. Ensure you're waiting for async operations in tests:
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})We welcome contributions! Please ensure:
- All memory cleanup is handled
- Hooks follow React Rules of Hooks
- TypeScript types are comprehensive
- Examples are provided for new hooks
MIT © Mateus Andrade