@omarsdev/react-native-contacts
v1.3.1
Published
Access the device address book and track when contacts were last touched.
Readme
@omarsdev/react-native-contacts
Contacts at scale for React Native, with fast first-run paging and efficient “delta since last sync” on Android and iOS.
Why this library
- Large address books without jank: fetch 10k+ contacts in pages (200–500) to keep UI responsive.
- Real delta sync: only fetch contacts that changed since your last run.
- Native tokens, minimal JS state: tokens are persisted natively to avoid storing massive lists in JS.
- Android sorted by last updated: newest changes first for better UX.
- iOS resilient strategy: uses Contacts change history when available, and a native fingerprint snapshot fallback when it isn’t.
Install
# with Yarn
yarn add @omarsdev/react-native-contacts
# with npm
npm install @omarsdev/react-native-contactsPermissions
Android: request
READ_CONTACTSat runtime before usage.Optional (manifest) — if you prefer declaring in your app too:
<!-- android/app/src/main/AndroidManifest.xml --> <manifest ...> <uses-permission android:name="android.permission.READ_CONTACTS" /> </manifest>Runtime request (JS):
import { PermissionsAndroid, Platform } from 'react-native'; export async function ensureContactsPermission() { if (Platform.OS !== 'android') return true; const res = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.READ_CONTACTS ); return res === PermissionsAndroid.RESULTS.GRANTED; }
iOS: add usage description to your app
Info.plistand rebuild pods.- Info.plist:
<key>NSContactsUsageDescription</key> <string>This app needs access to your contacts to sync changes.</string> - iOS will show the permission prompt the first time you access contacts.
- Info.plist:
Type shapes
type Contact = {
id: string;
displayName: string;
phoneNumbers: string[];
givenName?: string | null;
familyName?: string | null;
// Android only; iOS sets null
lastUpdatedAt?: number | null;
};
type PhoneNumberUpdate = {
previous: string;
current: string;
};
type PhoneNumberChanges = {
created: string[];
deleted: string[];
updated: PhoneNumberUpdate[];
};
type ContactChange = Contact & {
changeType: 'created' | 'updated' | 'deleted';
isDeleted: boolean;
phoneNumberChanges: PhoneNumberChanges;
previous?: {
displayName?: string | null;
givenName?: string | null;
familyName?: string | null;
phoneNumbers: string[];
} | null;
};
type UpdatedPageBase = {
nextSince: string;
totalContacts: number;
};
type UpdatedPage = UpdatedPageBase & {
mode: 'delta' | 'full';
items: ContactChange[];
};API reference & examples
Types
| Type | Description |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| type Contact | Normalised contact record returned by all APIs. Includes optional givenName, familyName, and lastUpdatedAt (Android only). |
| type PhoneNumberUpdate | Represents an individual phone number that changed within a contact delta (previous → current). |
| type PhoneNumberChanges | Buckets the numbers added/removed/updated in a ContactChange. Useful when reconciling diffs. |
| type ContactChange | Extends Contact with delta metadata (changeType, isDeleted, phoneNumberChanges, and an optional previous snapshot). |
| type UpdatedPage | Page returned by paging APIs (mode, items, nextSince, and totalContacts so you know the device book size). Items are always ContactChange[]. |
Functions (promise / async)
getById(id: string): Promise<Contact | null>- Look up a single contact by its native identifier. Resolves to
nullif the contact no longer exists or the identifier was empty.
const maybeAlice = await getById('42'); if (maybeAlice) { console.log('Found contact', maybeAlice.displayName); }- Look up a single contact by its native identifier. Resolves to
getPersistedSince(): Promise<string>- Reads the last token that was committed natively (empty string if nothing has been stored yet). Handy when resuming delta sync in a fresh JS session.
const lastToken = await getPersistedSince(); console.log('Native token:', lastToken);commitPersisted(nextSince: string): Promise<void>- Persists the supplied token on the native side so subsequent delta calls start from that point. On iOS this also rebuilds the fingerprint snapshot.
await commitPersisted(nextToken);getAll(): Promise<Contact[]>- Convenience helper that returns the full native contact list in one call.
const everyone = await getAll(); console.log('Fetched contacts', everyone.length);getUpdatedSincePaged(since: string, offset: number, limit: number): Promise<UpdatedPage>- Fetch a delta page using an explicit token. When native change tracking is available the result is
{ mode: 'delta' }with changed contacts and the next token. If the platform cannot supply a delta token—or you pass an empty token on first synchronisation—it returns{ mode: 'full' }so you can re-sync ordinary contact pages (still respectingoffset/limit). Token format differs by platform: Android always returns a millisecond timestamp, while iOS may return a base64-encodedCNChangeHistorytoken (e.g.YnBsaXN0…) or, when history is unavailable, a syntheticfp:<timestamp>token.
const page = await getUpdatedSincePaged(lastToken, 0, 200); if (page.mode === 'full') { page.items.forEach((change) => console.log('Full contact', change.id)); } else { page.items.forEach((change) => console.log(change.changeType, change.id)); } console.log('Total contacts on device', page.totalContacts);- Need to walk every page? Use
getUpdatedSincePaged.listento stream until exhaustion (returnfalsefrom the handler to stop early).pageSizeis optional and defaults to300.
await getUpdatedSincePaged.listen( { since: lastToken, pageSize: 250 }, async (page) => { console.log( `Page mode=${page.mode} size=${page.items.length} total=${page.totalContacts}` ); } );- Streaming signature:
getUpdatedSincePaged.listen(handler, options?)orgetUpdatedSincePaged.listen(options, handler). The handler can be async and should returnfalseto stop fetching.optionsaccepts{ since?: string; offset?: number; pageSize?: number }.
- Fetch a delta page using an explicit token. When native change tracking is available the result is
iOS tokens:
- Real change-history tokens look like long base64 strings (
YnBsaXN0MDD…).- Fallback fingerprints use the
fp:<timestamp>format when history is disabled or unchanged.
Quick start
import {
commitPersisted,
getPersistedSince,
getUpdatedSincePaged,
} from '@omarsdev/react-native-contacts';
import { ensureContactsPermission } from './permissions'; // from snippet above
// Delta or baseline sync (falls back to full pages when native tokens are unavailable)
if (await ensureContactsPermission()) {
const persistedSince = await getPersistedSince();
const pageSize = 300;
let nextSince: string | undefined = persistedSince;
let usedFullFallback = false;
await getUpdatedSincePaged.listen(
{ since: persistedSince, pageSize },
(page) => {
if (page.nextSince) nextSince = page.nextSince;
if (!page.items.length) return false;
const label = page.mode === 'full' ? 'Contacts page' : 'Delta page';
console.log(
`${label}: ${page.items.length} items (total contacts ${page.totalContacts})`
);
if (page.mode === 'full') usedFullFallback = true;
return page.items.length >= pageSize;
}
);
if (nextSince && nextSince !== persistedSince) {
await commitPersisted(nextSince);
} else if (usedFullFallback && !nextSince) {
console.log('Full snapshot processed; no token persisted yet.');
}
}Example scenarios
The example app in example/src/screens/ContactsDemoScreen.tsx walks through the most common flows. The snippets below highlight the key cases in isolation:
import {
commitPersisted,
getById,
getPersistedSince,
getUpdatedSincePaged,
} from '@omarsdev/react-native-contacts';
import type { Contact, ContactChange } from '@omarsdev/react-native-contacts';
// 1. Request permission on Android before touching contacts.
await ensureContactsPermission();
// 2. Pull the delta (or fallback full pages) since the last committed token and persist progress.
const persistedSince = await getPersistedSince();
const pageSize = 300;
let sessionToken = persistedSince;
let totalContacts: number | undefined;
const delta: ContactChange[] = [];
let fullFallback: Contact[] = [];
await getUpdatedSincePaged.listen(
{ since: persistedSince, pageSize },
(page) => {
if (page.nextSince) sessionToken = page.nextSince;
if (!page.items.length) return false;
totalContacts = page.totalContacts;
if (page.mode === 'delta') {
delta.push(...page.items);
} else {
fullFallback = fullFallback.concat(page.items);
}
return page.items.length >= pageSize;
}
);
if (sessionToken && sessionToken !== persistedSince) {
await commitPersisted(sessionToken);
}
console.log('Total contacts reported by native layer', totalContacts ?? 'unknown');
// 3. Full fallback pages can be handled like a baseline rebuild.
console.log('Full snapshot contacts (if fallback)', fullFallback.length);
// 4. Look up a single contact by identifier (helpful after any baseline rebuild).
const singleContact = await getById('12345'); // returns `null` if the contact was deletedPlatform details
- Android
- Uses
ContactsContract.Contacts.CONTACT_LAST_UPDATED_TIMESTAMPfor sorting and delta (timestamp filter). lastUpdatedAtis set from this value.
- Uses
- iOS
- Change history: uses CNContactStore change-history tokens when available.
- Fingerprint fallback: when change history is unavailable or returns no events, a native snapshot (id → fingerprint of name + normalized numbers) detects adds/edits; snapshot updates on
commitPersisted. - Synthetic tokens: when the system token doesn’t advance, we synthesize
fp:<timestamp>to ensure forward progress. - Delta payloads include
changeType, aprevioussnapshot (when available), andphoneNumberChangessummarising added, updated, and deleted numbers.
Recommended paging & usage
- Page size 200–500 works well for large books.
- Always commit a delta token after finishing a delta session.
- First run: do a full fetch; then do a delta to seed/commit a token.
Build & development
- Prereqs: Node 20+, Yarn 3 (Berry).
- Install deps and build the lib:
yarn(at repo root)yarn prepare(runs bob/codegen; generateslib/and TS types)
- Example app — Android:
cd example && yarn android
- Example app — iOS:
cd example/ios && pod installcd .. && yarn ios
- Using in your app:
yarn add @omarsdev/react-native-contacts- iOS:
cd ios && pod install - Rebuild the app
Why install
- You need to sync very large contact sets without freezing the UI.
- You want reliable “delta since last run” on both Android and iOS.
- You don’t want to persist huge JS arrays; tokens are handled natively.
- You want Android sorted by latest updates and an iOS strategy that works even when change history is unavailable.
License
MIT
Contributing
- See CONTRIBUTING.md for development workflow and conventions.
