The real-world impact of Shared Objects in Expo Modules
Product•Development••8 minutes read
Aleksander Mikucki
Engineering
Shared Objects solve a lot of fundamental problems with Expo APIs and also unlock a whole new way to design object-oriented APIs.

At Expo, we’ve spent a lot of time and effort over the last few years developing the Expo Modules API. We believe it to be the best way to write native code integrations for most app and library developers.
We’ve also rewritten all of the Expo SDK to this new API – the new modules are easier for us to maintain, take less time and have way less boilerplate. We can make more packages, fix bugs quicker, and keep up-to-date with the platforms easier.
We also love the number of community pull requests we’re seeing (thank you!) – this proves to us it’s actually easier for folks to contribute with the new APIs in place.
We keep on adding new functionalities to this API to ensure it’s the best way to add native functionalities to react native applications.
Today we wanted to talk about Shared Objects – a new major feature we’ve added to the Expo Modules API some time ago, but is now seeing use in several packages in SDK 52.
We’re really excited about it because it solves a lot of fundamental problems with our APIs and also unlocks a whole new way to design object-oriented APIs.
Let’s do a deep dive!
Why do we need Shared Objects?
It’s easiest to explain if I show you some an example from our docs (ok, tbh I simplified it a bit). This is how you would load a local asset, rotate it, and show it as an image in SDK 51:
const [image, setImage] = useState(null);useEffect(() => {(async () => {const image = Asset.fromModule(require('./assets/snack-icon.png'));await image.downloadAsync();const manipResult = await manipulateAsync(image.localUri || image.uri,[{ rotate: 90 }, { flip: FlipType.Vertical }],{ compress: 1, format: SaveFormat.PNG });setImage(manipResult);})();}, []);const renderImage = () => (<View style={styles.imageContainer}><Image source={{ uri: image.localUri || image.uri }} style={styles.image} /></View>);return (<View style={styles.container}>{image && renderImage()}</View>);
Though it works fine, there are a couple of drawbacks:
First, performance is not the best. The image is saved to disk once when downloading the asset, then again after manipulating it. This means it gets written to the cache directory twice, and does multiple trips between disk and memory – it would quickly become a problem in a list item or other reused components.
Ideally we would skip saving the file to disk between these calls, but we need to return something that represents an image to JavaScript – we used to be able to only return string URIs that pointed to a file on the disk.
This also makes the API for manipulating images seem overly verbose – while it is type-safe thanks to TypeScript, discovering available image operations is harder than it should be.
What’s with the weird API? Why can’t I just call image.rotate(90)? Well, we would need to save the image to disk after every call, so if you need to do multiple operations (rotate it, convert to grayscale, flip it, compress it – you get the gist) – this becomes very costly very quickly.
Again, we trade-off developer experience for performance because we can’t have both.
Or can we?
Enter Shared Objects – an elegant solution to these problems.
What are Shared Objects?
Shared Objects are a way to join together a native object and a JavaScript object. We can use the javascript object as a “pointer” to the underlying native object, and vice-versa.
This effectively means that the javascript object has some native state. We can pass the JS object between different JS calls and into native code without copying and moving data in memory. To best explain this, let’s look at an updated example of manipulating an image:
const [image, setImage] = useState(null);useEffect(() => {(async () => {const image = useImageManipulator(require('./assets/snack-icon.png'));const context = image.rotate(90).flip('vertical');const imageRef = await context.renderAsync();setImage(imageRef);})();}, []);const renderImage = () => (<View style={styles.imageContainer}><Image source={image} style={styles.image} /></View>);return (<View style={styles.container}>{image && renderImage()}</View>);
When comparing to the previous example, we can see a couple of things right off the bat:
- We no longer need to define all operations as a single call, so the API is much cleaner and shorter.
- We don’t need to render the image into a PNG just to display it. This means that the memory representation of the image created by the
renderAsyncfunction is the same one used by theImagecomponent.
Let’s now look at our context object – it’s of type ImageManipulatorContext.
The functions we can call on the context object are defined in our module definition written in Swift and Kotlin, and they allow us to access and modify it’s native state synchronously.
Class("Context", ImageManipulatorContext.self) {Function("flip") { (context: ImageManipulatorContext, flipType: FlipType) -> ImageManipulatorContext inreturn context.addTransformer(ImageFlipTransformer(flip: flipType))}...}
In this case, the flip() function adds an operation to a queue that is then applied by the renderAsync() function.
The queue is an example of some native state (an instance of a native class) that lives on a javascript context object.
The lifecycle of both objects is also connected, so the native object lives as long as the JS object.
What are Shared Refs?
This is all fine and dandy, but raises an interesting problem that we should be able to see in the image manipulator example.
The Image component comes from the expo-image package, and the useImageManipulator comes from expo-image-manipulator. This means that we need a single interface representing an image that can be used from both packages.
We could define it in the core of Expo Modules, create a separate package, or maybe have a it defined in both packages, but all these solutions have significant drawbacks (mostly dependency hell, version conflicts, and a heavier core).
We have a better solution – the thinking is that we can use platform-provided types that already represent what we need.
In our case we can represent an image in memory with the https://developer.apple.com/documentation/uikit/uiimage class on iOS and a https://developer.android.com/reference/android/graphics/Bitmap on Android.
We can link the imageRef to the native UIImage/Bitmap instance without any custom functions or state coming from the actual package. No custom functions means no need to share the interface between packages – the common types are provided by the OS itself.
We call this concept a Shared Ref:
internal final class ImageRef: SharedRef<UIImage> {override var nativeRefType: String {"image"}override func getAdditionalMemoryPressure() -> Int {guard let cgImage = ref.cgImage else {return 0}return cgImage.bytesPerRow * cgImage.height}}
This is a tiny implementation that can be safely reused in all of our packages. The Ref points to a UI image and can be safely unwrapped in a different Expo Modules package by calling the .ref accessor.
The nativeRefType string uniquely identifies the native type and allows us to perform argument validation (you cannot pass an image to something that expects a video). We can even define the approximate size in memory to help guide the Garbage Collector.
This is not an exclusive concept to Shared Objects – something can be both a Shared Object and a Shared Ref.
- Shared Objects → you can call some methods on it that are defined in package X as long as you received the object from some package X function.
- Shared Refs → You can pass the ref into a function exported from a totally different package that expects the same ref type.
Where else are we using Shared Objects and Shared Refs?
We actually rely on this quite heavily already in many new SDK packages.
Shared Objects are used in expo-video to represent players that can be passed into multiple views at the same time – it’s a new API and a powerful capability we’re really excited about. We also use them in a similar way in the new expo-audio package.
The new expo-file-system/next package provides a File(path) constructor that can be used to easily work with native files in an object-oriented way. A file instance is actually both a Shared Object (providing functions like .read() or .write()) and a Shared Ref – so that it will be accepted as an argument by other packages in the future.
What’s next for Shared Objects and Expo Modules?
We still have a couple conceptual problems that we’d like to solve.
For instance, it would be nice to create a common way to convert Shared Refs received from another package to a Shared Object to get that additional functionality.
We may want to do something like this, but exact details are still TBD:
const image = ExpoImage.load(url)// image is now a SharedRef<UIImage> + SharedObject from expo-imageconst myImage = image.cast(MyPackage.MyImage)// image is now a SharedRef<UIImage> + SharedObject from my-package// we can call myCustomSharedObjectFunction() on image now.myImage.myCustomSharedObjectFunction()
At a high-level, we want to make sure our SDK packages are as cross-compatible as possible. We think it’s a huge benefit to a broad ecosystem of packages that we have control over, so we aim to make common, every-day tasks you may need to do in React Native easier then ever.



