An open API service indexing awesome lists of open source software.

https://github.com/TheNaubit/expo-stable-id

Persistent cross-device user identifier for Expo
https://github.com/TheNaubit/expo-stable-id

cross-device expo expo-stable-id expostableid icloud keychain persistent-id react-native stable-id stableid user-identifier

Last synced: about 1 month ago
JSON representation

Persistent cross-device user identifier for Expo

Awesome Lists containing this project

README

          

# @nauverse/expo-stable-id

[![npm version](https://img.shields.io/npm/v/@nauverse/expo-stable-id.svg)](https://www.npmjs.com/package/@nauverse/expo-stable-id)
[![CI](https://github.com/TheNaubit/expo-stable-id/actions/workflows/ci.yml/badge.svg)](https://github.com/TheNaubit/expo-stable-id/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Persistent, cross-device user identifier for React Native/Expo. Port of [StableID](https://github.com/codykerns/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`](https://github.com/TheNaubit/expo-cloud-settings) (iCloud KVS on iOS, future Android support) - syncs across devices
- **Local**: [`expo-secure-store`](https://docs.expo.dev/versions/latest/sdk/securestore/) (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

```bash
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`:

```ts
export default {
plugins: ['@nauverse/expo-stable-id'],
};
```

This adds the iCloud KVS entitlement required for cloud sync. Optionally pass a custom container:

```ts
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.

```tsx
import { StableIdProvider, useStableId } from '@nauverse/expo-stable-id';

function App() {
return (



);
}

function MyComponent() {
const [id, { identify, generateNewId }] = useStableId();

return (

Stable ID: {id ?? 'Loading...'}
generateNewId()} />
identify('user-123')} />

);
}
```

#### Provider Config

```tsx
import { StandardGenerator, ShortIDGenerator } from '@nauverse/expo-stable-id';

// Use short 8-char IDs instead of UUIDs

// Provide a known ID, force it even if one exists

// Provide a fallback ID, prefer any stored value

```

### Functional API

```ts
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:

```tsx
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 (


{/* rest of your app */}

);
}

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:

```ts
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` | 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` | 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

```ts
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