The next generation of Expo APIs: MediaLibrary and Contacts

Product5 minutes read

Wiktor Smaga

Wiktor Smaga

Engineering

SDK 55 brings the next iteration of our core libraries. Shared Objects & Shared Refs fundamentally change how to interact with native data with these libraries.

The Next Generation of Expo APIs: MediaLibrary and Contacts

We believe Shared Objects and Shared Refs are the future for Expo libraries, because they provide deeper integration between modules without unnecessary coupling. To read more about shared objects and shared refs, check out this article.

In SDK 54 we updated the File System with the new object-oriented API. Now, it's time for Contacts and MediaLibrary to get their upgrade. The updated versions of the libraries are available as expo-contacts/next and expo-media-library/next. We will continue to bring these features to other libraries in upcoming SDK releases!

Now, let’s dive into what has changed in the contacts and media library packages.

MediaLibrary@Next

Core concept

The new API is built around a simple idea: Album and Asset are now classes that work as proxies for the native data. They hold a reference to the objects in the system gallery to easily fetch metadata.

To create a new asset, you must pass a local URI to the static Asset.create() function. This command adds a file to the gallery and returns a shared object:

Code
// Create an asset from a local URI
const asset = await Asset.create('file:///path/to/photo.png');

Once you have an asset, managing it is much more intuitive:

Code
// Fetch an album and add the asset directly
const album = await Album.get('Holiday 2026');
await album.add(asset);
// Or create a new album and add the asset in one go
const album = await Album.create('Holiday 2026', [asset]);

Direct Access to Properties

The common bottleneck in the old API was the size of the object returned by the getAssetInfoAsync() function. You couldn't simply check the location without fetching a massive plain object containing every single property. MediaLibrary@Next provides granular getters that let you access exactly what you need. No more fetching the entire metadata object just to check the dimensions of an image.

Code
await asset.getLocation(); // { longitude: 50, latitude: 20 }
await asset.getShape(); // { width: 100, height: 100 }
// Need everything? You can still do that:
await asset.getInfo();

Powerful queries

MediaLibrary@Next introduces a new way to filter media. The new Query class draws on the builder pattern, allowing you to create more specific queries in a highly readable way.

It supports the following predicates: gt, gte, lte, lt, eq, within and album - along with orderBy for sorting and limit/offset for pagination.

Code
const assets = await new Query()
.eq(AssetField.MEDIA_TYPE, MediaType.IMAGE)
.lte(AssetField.HEIGHT, 1080)
.within(AssetField.WIDTH, [920, 960, 1080])
.orderBy(AssetField.CREATION_TIME)
.limit(20)
.offset(10)
.exe();

Contacts@Next

Core concept

We designed the new API based on the following assumptions:

Contact objects in Contacts@Next hold a reference to the system contacts and contain a lot of async functions to fetch or update the underlying system contacts state. Unlike in the old API, you don't have to pass IDs manually in every function call, and you can rely solely on the update function called on an instance of a specific contact, which avoids a lot of easy-to-make mistakes.

To get started, you can create, fetch, or pick a contact using static methods:

Code
// Create a new contact
const contact = await Contact.create({
givenName: 'Andrew',
familyName: 'Jones',
phones: [
{
label: 'mobile',
number: '+12123456789',
},
],
});
// Fetch existing contact
const [contact] = await Contact.getAll({ limit: 1 });
// Or pick one using the system UI
const contact = await Contact.presentPicker();

Granular updates

No one likes dealing with massive JavaScript objects just to update a single property. For instance, if you wanted to add a new email address to a contact in the legacy API, you had to modify and send back the entire object:

Code
// THE OLD WAY
const updatedContact = {
...contact,
emails: [...(contact.emails || []), { address: 'contacts-next@expo.dev', label: 'work' }],
};
await Contacts.updateContactAsync(updatedContact);

Now you can perform the same operation with just one line of code:

Code
// THE NEW WAY
await contact.addEmail({ address: 'contacts-next@expo.dev', label: 'work' });

And you can do this with every contact property! Look how easy it is to set a profile picture now:

Code
const result = await ImagePicker.launchImageLibraryAsync();
await contact.setImage(result.assets[0].uri);

Performant querying

The same goes for querying - if you want to fetch just one specific field, you can use granular getters:

Code
const givenName = await contact.getGivenName();

To get more than just one property, use getDetails. Simply pass an array of the fields you are interested in:

Code
await contact.getDetails([ContactField.EMAILS, ContactField.FULL_NAME]);

And if you are building a list of contacts, there is a more efficient function for this: getAllDetails(). Under the hood, it uses a single system API call, making it very performant. It also supports different types of filtering as an optional parameter.

Code
// This is a static module function.
await Contact.getAllDetails([ContactField.EMAILS, ContactField.FULL_NAME]);

Additionally, objects returned by these functions have a narrowed type based on the fields requested in the arguments. TypeScript will warn you if you try to access a field that hasn't been fetched.

Code
const details = await contact.getDetails([ContactField.GIVEN_NAME]);
console.log(details.givenName); // ✅ "John"
console.log(details.phones); // ❌ TypeScript will show a warning

.patch() + .getDetails() = <3

The new version of the library includes a patch() method - similar to the one in the HTTP protocol - which applies partial modifications. It skips undefined properties, so you only update the fields you actually want to change.

It has been designed to work smoothly with getDetails(): while getDetails() fetches only requested fields, patch() updates only the defined ones. This is perfect for forms where you only change one or two fields:

Code
const details = await contact.getDetails([ContactField.EMAILS, ContactField.GIVEN_NAME]);
details.givenName = 'John';
await contact.patch(details); // Only updates the givenName in the system.

If you want to completely replace the contact properties, you can still use the update method. Unlike patch, it overrides the entire contact:

Code
await contact.update({
givenName: 'New Name',
familyName: 'New Surname',
// Other fields will be cleared
});

Check out the new documentation

Since this is the next iteration of these libraries, your input is more important than ever. We’re open and extremely grateful for any feedback, GitHub issues, feature requests, or tweets you want to send our way.

We also invite you to check out the new docs, where you will find examples of how the APIs work in action!

Shared Objects
Shared Refs

React Native CI/CD for Android, iOS, and Web

Learn more