Skip to content

TheNaubit/expo-stable-id

Repository files navigation

@nauverse/expo-stable-id

npm version CI License: MIT

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:

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-crypto

Config 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: StableIdProvider does not require CloudSettingsProvider from @nauverse/expo-cloud-settings as an ancestor. Internally it uses the functional API (getString, setString, addChangeListener) from @nauverse/expo-cloud-settings directly. If your app also uses CloudSettingsProvider for 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