expo-contact-picker
v0.0.3
Published
Native iOS contact picker for Expo — SwiftUI-powered with a clean JS API
Maintainers
Readme
expo-contact-picker
A high-quality, SwiftUI-powered native iOS contact picker for Expo. Opens the familiar iOS Contacts UI, lets users pick one or many contacts, and returns structured data to JavaScript with full TypeScript support.
Platform: iOS only (v1). Android support is planned for a future release.
Table of Contents
- Features
- Requirements
- Installation
- Setup
- Quick Start
- API Reference
- Examples
- Permissions
- Architecture
- Troubleshooting
- Contributing
- License
Features
- Native iOS UI — uses
CNContactPickerViewController, the same picker Apple ships in the Contacts app - Single & multi-select — controlled via a single
multipleoption - Field filtering — grey out contacts that lack phone numbers or email addresses
- Typed API — TypeScript overloads ensure the return type matches the
multipleflag at compile time - No explicit permission call needed —
CNContactPickerViewControllerhandles Contacts authorization internally - Graceful non-iOS stub — importing the module on Android or web never crashes; only calling
openContactPickerthrows an informative error
Requirements
| Tool | Minimum version | |---|---| | Expo SDK | 50+ | | React Native | 0.73+ | | iOS | 15.0+ | | Xcode | 15+ | | Node | 18+ |
Installation
# npm
npm install expo-contact-picker
# yarn
yarn add expo-contact-picker
# pnpm
pnpm add expo-contact-pickerThen rebuild the native app:
npx expo prebuild --clean
npx expo run:iosExpo Go — native modules are not supported in Expo Go. Use a development build instead.
Setup
1. Add the privacy description (required by Apple)
Edit your app.json / app.config.js and add a NSContactsUsageDescription:
// app.json
{
"expo": {
"ios": {
"infoPlist": {
"NSContactsUsageDescription": "We need access to your contacts so you can select who to send money to."
}
}
}
}Apple will reject your app during review if this key is missing.
2. Prebuild / link
npx expo prebuildThe podspec (ExpoContactPicker.podspec) is picked up automatically by CocoaPods during prebuild. No manual Podfile edits are required.
3. Run on device or simulator
npx expo run:iosQuick Start
import { openContactPicker } from 'expo-contact-picker';
import { Button, Text, View } from 'react-native';
export default function App() {
const handlePick = async () => {
const contact = await openContactPicker();
if (contact) {
console.log(contact.name); // "Jane Appleseed"
console.log(contact.phoneNumbers); // ["+1 (555) 123-4567"]
}
};
return (
<View>
<Button title="Pick a contact" onPress={handlePick} />
</View>
);
}API Reference
openContactPicker(options?)
Opens the native iOS contact picker. Returns a Promise that resolves when the user selects a contact or cancels.
// Single-select (default)
function openContactPicker(options?: PickerOptions & { multiple?: false }): Promise<Contact | null>
// Multi-select
function openContactPicker(options: PickerOptions & { multiple: true }): Promise<Contact[]>Return values
| Scenario | Return value |
|---|---|
| User picks a contact (single) | Contact object |
| User cancels (single) | null |
| User picks contacts (multi) | Contact[] (length ≥ 1) |
| User cancels (multi) | Contact[] (empty array []) |
Throws
Throws a ContactPickerError for hard errors (permission denied, no view controller). Cancellation is not an error — it returns null / [].
ContactPickerError
Extends the built-in Error class with an extra code property.
class ContactPickerError extends Error {
code: NativeErrorCode;
}Error codes
| code | When it occurs |
|---|---|
| PERMISSION_DENIED | The user previously denied Contacts access in Settings |
| PERMISSION_RESTRICTED | Contacts access is restricted by a device policy (MDM, parental controls) |
| NO_VIEW_CONTROLLER | Could not find a UIViewController to present the picker from (rare) |
| UNKNOWN_ERROR | Unexpected native error |
import { openContactPicker, ContactPickerError } from 'expo-contact-picker';
try {
const contact = await openContactPicker();
} catch (e) {
if (e instanceof ContactPickerError) {
switch (e.code) {
case 'PERMISSION_DENIED':
// Prompt the user to open Settings → Privacy → Contacts
break;
case 'PERMISSION_RESTRICTED':
// Inform the user their device policy blocks Contacts
break;
default:
console.error(e.message);
}
}
}Types
Contact
type Contact = {
/** Stable iOS contact identifier (CNContact.identifier). */
id: string;
/** Full formatted display name, e.g. "Jane Appleseed". */
name: string;
/** Given / first name. */
firstName?: string;
/** Family / last name. */
lastName?: string;
/** All phone number strings, e.g. ["+1 (555) 123-4567", "+44 7911 123456"]. */
phoneNumbers: string[];
/** All email address strings, e.g. ["[email protected]"]. */
emails: string[];
};PickerOptions
type PickerOptions = {
/**
* Allow the user to select more than one contact.
* When true the picker shows checkboxes and a Done button.
* Defaults to false.
*/
multiple?: boolean;
/**
* Restrict selectable contacts by the fields they contain.
* Contacts lacking the requested fields are shown but greyed out.
*
* "phone" — contact must have at least one phone number
* "email" — contact must have at least one email address
*
* Passing both ["phone", "email"] enables contacts that have
* either a phone number OR an email address.
*
* Omit or pass [] to enable all contacts.
*/
fields?: ('phone' | 'email')[];
};ContactField
type ContactField = 'phone' | 'email';Examples
Single contact selection
import { openContactPicker } from 'expo-contact-picker';
const contact = await openContactPicker();
if (contact === null) {
// User tapped Cancel
return;
}
console.log(contact.id); // "E24B4E64-6B28-..."
console.log(contact.name); // "Jane Appleseed"
console.log(contact.firstName); // "Jane"
console.log(contact.lastName); // "Appleseed"
console.log(contact.phoneNumbers); // ["+1 (555) 123-4567"]
console.log(contact.emails); // ["[email protected]"]Multi-contact selection
import { openContactPicker } from 'expo-contact-picker';
const contacts = await openContactPicker({ multiple: true });
if (contacts.length === 0) {
// User tapped Cancel without selecting anyone
return;
}
contacts.forEach(c => {
console.log(`${c.name}: ${c.phoneNumbers.join(', ')}`);
});Filter by field
// Only contacts with a phone number are selectable
const contact = await openContactPicker({ fields: ['phone'] });
// Only contacts with an email are selectable
const contact = await openContactPicker({ fields: ['email'] });
// Contacts with either a phone OR email are selectable
const contact = await openContactPicker({ fields: ['phone', 'email'] });Full error handling
import { openContactPicker, ContactPickerError } from 'expo-contact-picker';
import { Alert, Linking } from 'react-native';
async function pickContact() {
try {
const contact = await openContactPicker({ fields: ['phone'] });
if (!contact) return; // cancelled
sendMoney(contact.phoneNumbers[0]);
} catch (e) {
if (e instanceof ContactPickerError) {
if (e.code === 'PERMISSION_DENIED') {
Alert.alert(
'Contacts Access Required',
'Please enable Contacts access in Settings to use this feature.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Open Settings', onPress: () => Linking.openSettings() },
],
);
} else {
Alert.alert('Error', e.message);
}
}
}
}React hook wrapper
A convenient custom hook you can drop into your project:
// hooks/useContactPicker.ts
import { useState, useCallback } from 'react';
import {
openContactPicker,
ContactPickerError,
type Contact,
type PickerOptions,
} from 'expo-contact-picker';
type State = {
contact: Contact | null;
loading: boolean;
error: ContactPickerError | null;
};
export function useContactPicker(options?: PickerOptions & { multiple?: false }) {
const [state, setState] = useState<State>({
contact: null,
loading: false,
error: null,
});
const pick = useCallback(async () => {
setState(s => ({ ...s, loading: true, error: null }));
try {
const contact = await openContactPicker(options);
setState({ contact, loading: false, error: null });
return contact;
} catch (e) {
const error = e instanceof ContactPickerError ? e : new ContactPickerError('UNKNOWN_ERROR');
setState(s => ({ ...s, loading: false, error }));
return null;
}
}, [options]);
return { ...state, pick };
}Usage:
import { useContactPicker } from './hooks/useContactPicker';
import { Button, Text, View } from 'react-native';
export default function SendMoney() {
const { contact, loading, error, pick } = useContactPicker({ fields: ['phone'] });
return (
<View>
<Button title="Select Recipient" onPress={pick} disabled={loading} />
{contact && <Text>Sending to: {contact.name}</Text>}
{error?.code === 'PERMISSION_DENIED' && (
<Text>Please allow Contacts access in Settings.</Text>
)}
</View>
);
}Permissions
CNContactPickerViewController manages the Contacts permission prompt automatically on first use — you do not need to call Contacts.requestPermissionsAsync() yourself.
However you must add NSContactsUsageDescription to your Info.plist (see Setup), otherwise:
- iOS 17+: the app will crash at launch
- App Store review: automatic rejection
Permission flow
openContactPicker()
│
▼
Not determined? ──yes──▶ iOS shows system permission alert
│ │
│no Granted?
▼ yes ──▶ picker opens
Denied/Restricted? no ──▶ ContactPickerError('PERMISSION_DENIED')
│
▼
ContactPickerError('PERMISSION_DENIED' | 'PERMISSION_RESTRICTED')Architecture
┌─────────────────────────────────────────────────────────┐
│ JavaScript / TypeScript │
│ │
│ openContactPicker(options) ──▶ ContactPickerError │
│ │ │
│ src/index.ts (overloads, error shaping) │
│ │ │
│ src/ExpoContactPickerModule.ts (requireNativeModule) │
└──────────────────────┬──────────────────────────────────┘
│ Expo Modules Core bridge
┌──────────────────────▼──────────────────────────────────┐
│ Swift — ios/ │
│ │
│ ExpoContactPickerModule.swift │
│ ├─ Module definition (Name / AsyncFunction) │
│ ├─ PickerOptions Record (@Field multiple, fields) │
│ ├─ topViewController() helper │
│ ├─ enablePredicate(fields) — CNContact NSPredicate │
│ └─ contactToDict(_ contact: CNContact) │
│ │
│ ContactPickerDelegate.swift │
│ ├─ SingleSelectDelegate : CNContactPickerDelegate │
│ │ didSelect contact: → resolve([contact]) │
│ │ contactPickerDidCancel → resolve(USER_CANCELLED) │
│ └─ MultiSelectDelegate : CNContactPickerDelegate │
│ didSelect contacts: → resolve([...contacts]) │
│ contactPickerDidCancel → resolve(USER_CANCELLED) │
└─────────────────────────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────┐
│ iOS Contacts Framework │
│ CNContactPickerViewController │
│ CNContact · CNContactFormatter · CNLabeledValue │
└─────────────────────────────────────────────────────────┘Why two delegate classes?
CNContactPickerViewController detects the selection mode by inspecting which delegate methods are implemented:
- Only
didSelect contact:(singular) → single-select mode (no checkboxes) - Only
didSelect contacts:(plural) → multi-select mode (checkboxes + Done button)
By keeping SingleSelectDelegate and MultiSelectDelegate as separate classes that each implement only one variant, the native picker automatically shows the correct UI with zero extra configuration.
The delegate retention pattern
The active delegate is stored as a strong reference in ExpoContactPickerModule.activeDelegate. This ensures the delegate is not deallocated while the picker is on screen. The onDismiss closure passed to each delegate sets activeDelegate = nil when the picker finishes, releasing the reference.
Troubleshooting
"Native module ExpoContactPicker not found"
- Make sure you ran
npx expo prebuildandnpx expo run:iosafter installing - Expo Go is not supported — use a development build
- Check that the podspec file is present and
pod installran successfully
The picker opens but contacts are all greyed out
When you pass fields: ['phone'], contacts without phone numbers are disabled. If all your test contacts happen to have no numbers stored in the simulator, add a phone number to one of them in the Contacts app.
The app crashes with NSContactsUsageDescription missing
Add the key to your app.json under expo.ios.infoPlist and rebuild (see Setup).
"openContactPicker is only supported on iOS"
You are calling openContactPicker on Android or web. Guard the call with a platform check:
import { Platform } from 'react-native';
if (Platform.OS === 'ios') {
const contact = await openContactPicker();
}TypeScript: "Argument of type '{ multiple: true }' is not assignable…"
Make sure you are on TypeScript 4.9+ and have imported from 'expo-contact-picker' (not a deep path). Restart the TS server (Cmd+Shift+P → TypeScript: Restart TS Server in VS Code).
Contributing
Pull requests are welcome. For major changes, open an issue first to discuss the approach.
git clone https://github.com/iraosandeep/expo-contact-picker.git
cd expo-contact-picker
npm install
npm run buildTo test changes in a real app, use npm pack to create a tarball and install it:
npm pack
# in your test app:
npm install ../expo-contact-picker/iraosandeep-expo-contact-picker-1.0.0.tgz
npx expo run:iosLicense
MIT © iraosandeep
