@nauverse/expo-stable-id
v2.0.0
Published
Persistent cross-device user identifier for Expo (port of StableID)
Maintainers
Readme
@nauverse/expo-stable-id
Persistent, cross-device user identifier for React Native/Expo. Port of StableID (Swift) to the Expo ecosystem, big thanks to him for that awesome lib!
How it works
expo-stable-id provides a persistent user identifier using dual storage:
- Cloud:
@nauverse/expo-cloud-settings(iCloud KVS on iOS, future Android support) - syncs across devices - Local:
expo-secure-store(Keychain on iOS, Android Keystore) - persists across app reinstalls
The ID is generated once and persisted to both storages. On subsequent launches, the stored ID is read back. When iCloud syncs a new ID from another device, the local copy is updated.
Installation
npx expo install @nauverse/expo-stable-id @nauverse/expo-cloud-settings expo-secure-store expo-cryptoConfig Plugin
Add to your app.config.ts / app.json:
export default {
plugins: ['@nauverse/expo-stable-id'],
};This adds the iCloud KVS entitlement required for cloud sync. Optionally pass a custom container:
export default {
plugins: [
['@nauverse/expo-stable-id', { containerIdentifier: 'com.example.shared' }],
],
};Usage
React Hooks (Recommended)
Note:
StableIdProviderdoes not requireCloudSettingsProviderfrom@nauverse/expo-cloud-settingsas an ancestor. Internally it uses the functional API (getString,setString,addChangeListener) from@nauverse/expo-cloud-settingsdirectly. If your app also usesCloudSettingsProviderfor its own React hooks (useCloudSetting*), both providers are independent and can be placed in any order.
import { StableIdProvider, useStableId } from '@nauverse/expo-stable-id';
function App() {
return (
<StableIdProvider>
<MyComponent />
</StableIdProvider>
);
}
function MyComponent() {
const [id, { identify, generateNewId }] = useStableId();
return (
<View>
<Text>Stable ID: {id ?? 'Loading...'}</Text>
<Button title="New ID" onPress={() => generateNewId()} />
<Button title="Set Custom" onPress={() => identify('user-123')} />
</View>
);
}Provider Config
import { StandardGenerator, ShortIDGenerator } from '@nauverse/expo-stable-id';
// Use short 8-char IDs instead of UUIDs
<StableIdProvider config={{ generator: new ShortIDGenerator() }}>
// Provide a known ID, force it even if one exists
<StableIdProvider config={{ id: 'known-user-id', policy: 'forceUpdate' }}>
// Provide a fallback ID, prefer any stored value
<StableIdProvider config={{ id: 'fallback-id', policy: 'preferStored' }}>Functional API
import {
configure,
getId,
identify,
generateNewId,
isConfigured,
hasStoredId,
addChangeListener,
setWillChangeHandler,
} from '@nauverse/expo-stable-id';
// Initialize (call once at app startup)
const id = await configure();
// Or with options
const id = await configure({
id: 'fallback-id',
policy: 'preferStored',
generator: new ShortIDGenerator(),
});
// Read current ID (sync, from cache)
const currentId = getId();
// Change ID
identify('new-user-id');
// Generate a new random ID
const newId = generateNewId();
// Check state
isConfigured(); // boolean
await hasStoredId(); // boolean
// Listen for changes
const subscription = addChangeListener((event) => {
console.log(`ID changed: ${event.previousId} -> ${event.newId} (${event.source})`);
});
subscription.remove();
// Intercept changes before they apply (identify, generateNewId, cloud sync)
setWillChangeHandler((currentId, candidateId) => {
// Return modified ID, or null to accept candidate as-is
return candidateId;
});Real-World Example: Shared ID for PostHog + RevenueCat
Use the same stable ID across your analytics and payment provider so user events are always linked:
import { StableIdProvider, useStableId } from '@nauverse/expo-stable-id';
import PostHog from 'posthog-react-native';
import Purchases from 'react-native-purchases';
import { useEffect } from 'react';
function App() {
return (
<StableIdProvider>
<IdentifyProviders />
{/* rest of your app */}
</StableIdProvider>
);
}
function IdentifyProviders() {
const [id] = useStableId();
useEffect(() => {
if (!id) return;
// Same ID in both services
PostHog.identify(id);
Purchases.logIn(id);
}, [id]);
return null;
}ID Generators
| Generator | Output | Example |
|-----------|--------|---------|
| StandardGenerator (default) | UUID v4 | a1b2c3d4-e5f6-4789-abcd-ef0123456789 |
| ShortIDGenerator | 8-char alphanumeric | xK9mP2nQ |
Custom generators implement the IDGenerator interface:
import type { IDGenerator } from '@nauverse/expo-stable-id';
const myGenerator: IDGenerator = {
generate: () => `prefix-${Date.now()}`,
};Policies
| Policy | Behavior |
|--------|----------|
| 'forceUpdate' (default) | Always use the provided id (if given) |
| 'preferStored' | Use stored ID if available, fall back to provided id |
Platform Support
| Feature | iOS | Android | |---------|-----|---------| | Local storage (Keychain/Keystore) | Yes | Yes | | Cloud sync (iCloud KVS) | Yes | Coming soon | | ID generation | Yes | Yes |
API Reference
Functional API
| Function | Returns | Description |
|----------|---------|-------------|
| configure(config?) | Promise<string> | Initialize and get/create stable ID |
| getId() | string \| null | Current cached ID (sync) |
| identify(id) | void | Set a specific ID |
| generateNewId() | string | Generate and persist a new ID |
| isConfigured() | boolean | Whether configure() has been called |
| hasStoredId() | Promise<boolean> | Whether an ID exists in storage |
| addChangeListener(cb) | { remove: () => void } | Subscribe to ID changes |
| setWillChangeHandler(fn) | void | Intercept all ID changes before they apply |
React API
| Export | Description |
|--------|-------------|
| StableIdProvider | Context provider, call configure() internally |
| useStableId() | [id, { identify, generateNewId }] |
Types
type IDPolicy = 'preferStored' | 'forceUpdate';
type ChangeSource = 'cloud' | 'manual';
interface IDGenerator {
generate(): string;
}
interface StableIdConfig {
readonly id?: string;
readonly generator?: IDGenerator;
readonly policy?: IDPolicy;
}
interface StableIdChangeEvent {
readonly previousId: string | null;
readonly newId: string;
readonly source: ChangeSource;
}Feature Mapping from StableID (Swift)
| StableID (Swift) | expo-stable-id | Notes |
|------------------|----------------|-------|
| StableID.configure(id?, generator, policy) | configure(config?) | Same semantics |
| StableID.id | getId() / useStableId()[0] | Sync cached read |
| StableID.identify(id:) | identify(id) | Writes both storages |
| StableID.generateNewID() | generateNewId() | Uses configured generator |
| StableID.isConfigured | isConfigured() | Static check |
| StableID.hasStoredID | hasStoredId() | Checks both storages |
| StableID.set(delegate:) | addChangeListener() + setWillChangeHandler() | JS-idiomatic |
| StandardGenerator | StandardGenerator | UUID v4 |
| ShortIDGenerator | ShortIDGenerator | 8-char alphanumeric |
License
MIT
