Ways to pass objects between native and JavaScript in React Native

This is the second part of our story on how we built a React Native bridge for Shopify’s iOS and Android SDK. In the first part, I described how to set up the project structure for the bridge library. With that out of the way, it’s time to write methods that will expose the native APIs to JavaScript. This article will be a bit longer than the first one. There’s a lot of ground to cover!

The official documentation explains the basics of native modules. But, sometimes you’ll need to handle more complex cases. For example, we had to pass most of Shopify’s data model, such as products and collections, to JavaScript. We also had to create native objects from ones we passed from JavaScript.

One such example was creating a native checkout from a collection of cart items. Since a lot can go wrong during a checkout, we also needed a way to handle native errors. We thought: how hard can this be?

But, we couldn’t find all the answers in the documentation, so we had to dig deeper. We learned a lot along the way, and I’m here to share this with you. This article lists recipes and best practices for various use cases you’ll encounter.

Passing complex data structures from native to JavaScript

The first challenging bridge method we implemented was getting a list of products. Both the iOS and Android SDK have custom models for products. The models have nested objects such as variants. SDK methods for fetching products all return these native objects. If you try to send them over the bridge, you’ll get an error.

A React Native bridge method works only with standard JSON types. These are strings, numbers, booleans, arrays and maps. You’ll need to convert native objects to structures that have only these types. For iOS, you’ll have to transform native objects to NSDictionaries. With Android, you’ll need WritableMaps and WritableArrays for objects and collections.

You could create these data structures on your own. One way is to create an empty dictionary and add an entry for each property of the native object. These objects can have dozens of properties and even nested objects. The manual approach can become cumbersome.

Converting objects for iOS — the easy and hard way

For iOS, we noticed that the SDK has a JSONDictionary method. At first, we thought that this was a method of all native objects. It turned out that the SDK had it as a helper method. It helped with serialization and deserialization when making calls to Shopify API.

There’s a high chance that the SDK you’re bridging will have a similar method. You might not see it in the documentation but try to find it by digging through the source files. The difference between using such a method and the manual way is outstanding:

/**
* This method adds options for variants the manual way.
* The SDK’s JSONDictionary method doesn’t include them.
*/
- (NSArray *) getDictionariesForProducts:(NSArray<BUYProduct *> *)products 
{
NSMutableArray *result = [NSMutableArray array];

for (BUYProduct *product in products) {
NSMutableDictionary *productDictionary = [[product
JSONDictionary
] mutableCopy];
   NSMutableArray *variants = [NSMutableArray array]; 
for (BUYProductVariant *variant in product.variants) {
// A lot of code to create a dictionary for a variant
}
productDictionary[@”variants”] = variants;
[result addObject: productDictionary];
}
return result;
}

In the above example, you can see both approaches and a common pattern that we used. Where we could get the entire data structure that way, we used the JSONDictionary method. Where there was a missing implementation, we created a dictionary. Then we added all the entries we needed. You can use the pattern above in your own code.

When creating native objects from their JavaScript counterparts, the same principles apply. You get NSArrays and NSDictionaries in your native code. To convert them, first look for SDK specific methods. In our case, Shopify had a method to create a product variant from a dictionary. We used it to create a native cart object from an array of JavaScript cart items:

- (BUYCheckout *) createCheckoutFromCart:(NSArray *)cartItems
{
BUYModelManager *modelManager = self.client.modelManager;
  BUYCart *cart = [modelManager insertCartWithJSONDictionary:nil]; 

for (NSDictionary *cartItem in cartItems) {
    BUYProductVariant *variant = [[BUYProductVariant alloc]    
initWithModelManager:modelManager
JSONDictionary:cartItem[@"variant"]]
;
    for(int i = 0; i < [cartItem[@"quantity"] integerValue]; i++) {                  
      [cart addVariant:variant];        
}
}
BUYCheckout *checkout = [modelManager checkoutWithCart:cart];
return checkout;
}

I’ve bolded the part where the SDK creates an object for us. This a similar pattern to what we’ve seen in the previous example.

Converting objects for Android and a few helper methods

With the Android SDK things are a bit different. For most objects, we couldn’t find a method to get a dictionary we could send over the bridge. But, the pattern to convert a Java object to a WritableMap and vice versa is simple:

  1. Serialize the object to a JSON string. You can do this with the help of Gson, a well-known library.
  2. Create a new instance of JSONObject from the JSON string.
  3. Create a new WritableMap instance.
  4. Iterate through the keys of the JSONObject instance.
  5. Check each value using the instanceof operator. If it’s a simple type, put it in the WritableMap instance. If it’s a JSONArray or another JSONObject, iterate through it.

You’ll need recursive methods to convert JSON Arrays/Objects to Writable Arrays/Maps. We found a Gist online that had these methods, and used it in our code. Let’s see how this recipe plays out in practice. We’ll use it to convert a list of Product objects to a WritableArray.

The Shopify SDK already implements a helper method to convert a Product object to JSON:

/** 
* @return A JSON representation of this object.
*/
public String toJsonString() {
return BuyClientUtils.createDefaultGson().toJson(this);
}

Even if the SDK you use doesn’t have such a method, you can do it with Gson. It’s easy, as you can see from the implementation:

public static Gson createDefaultGson() {             
  GsonBuilder builder = new GsonBuilder();
return builder.create();
}

I’ve omitted the customization parts of the implementation. For some objects, you’ll need to help Gson out. This depends on the structure of the object. You can find more information in Gson’s documentation.

We convert a list of Product objects to a WritableArray by iterating through it. For each product, we create its WritableMap representation.

private WritableArray getProductsAsWritableArray(List<Product>      
products) throws JSONException {
  WritableArray array = new WritableNativeArray();     
for (Product product : products) {
WritableMap productMap = convertJsonToMap(new
JSONObject(product.toJsonString()))
;
   array.pushMap(productMap);    
}
return array;
}

The convertJsonToMap method is the recursive converter I mentioned above. You can find its implementation on this Gist:

You can also find implementations for other conversion methods. There’s four of them. They convert JSON Arrays/Maps to Writable Arrays/Maps and vice versa. Speaking of conversion in the opposite way, the recipe is the same, but in reverse.

Note that in this case, the input to native code will be a Readable and not a WritableArray. They are almost the same, except that a ReadableArray is for reading values, not writing. Here’s how we created a native Cart object from a ReadableArray of cart items:

cart = new Cart();      
for (int i = 0; i < cartItems.size(); i++) {
ReadableMap cartItem = cartItems.getMap(i);
ReadableMap variantDictionary = cartItem.getMap("variant");
  int quantity = cartItem.getInt("quantity");        
JSONObject variantAsJsonObject =
convertMapToJson(variantDictionary);


ProductVariant variant =
fromVariantJson(variantAsJsonObject.toString());


for(int j = 0; j < quantity; j++) {
cart.addVariant(variant);
}
}
private ProductVariant fromVariantJson(String json) {    
return BuyClientUtils.createDefaultGson().fromJson(json,
ProductVariant.class);
}

The bulk of the work was to create ProductVariant objects. First, we created a JSONObject instance from a ReadableMap with the helper method. Then we extracted its underlying JSON string. Finally, we used Gson to create an object from this JSON string and a class descriptor. Actually, we used Gson through the SDK’s wrapper. You saw the implementation earlier.

You can see that for Android you don’t even need native SDK helpers. You can use a combination of Gson and the above-mentioned utility methods. To reuse them in many parts of your bridge, put them in a utility class.

Creating errors users can understand

Sometimes a bridge call can fail and an error object can contain a lot of useful data. It might be the only way to figure out what went wrong. A checkout on Shopify is one such example. A checkout error has a structure with deep nesting:

{"errors":
{"checkout":
{
"email":[{
"code":"invalid",
"message":"is invalid",
"options":{}
}],
"shipping_address":{
"country_code":[{
"code":"not_supported",
"message":"is not supported",
"options":{}
}]
},
"billing_address":{
"country_code":[{
"code":"not_supported",
"message":"is not supported",
"options":{}
}]
}
}
}
}

With iOS, there’s a standard way of rejecting a promise:

if (error) {            
return reject(
[NSString stringWithFormat: @"%lu", (long)error.code],
error.localizedDescription,
error
);
}

The localizedDescription and userInfo properties contain the error message. This works for simple cases. For errors such as the one from Shopify we saw above, you’ll get no useful description.

We solved this challenge by using a neat trick. Since the userInfo property of an error object is an NSDictionary, we serialized it to JSON. Then we sent this string to JavaScript:

- (NSString *) getJsonFromError:(NSError *)error
{
NSError * err;
NSData * jsonData = [NSJSONSerialization
dataWithJSONObject:error.userInfo
options:0 error:&err];

return [[NSString alloc] initWithData:jsonData
encoding:NSUTF8StringEncoding];
}

In JavaScript land, we parsed it to get a message we could show to the user.

const checkout = (cart) => {    
return RNShopify.checkout(cart).catch((error) => {
throw new Error(getCheckoutError(error.message, cart));
});

const getCheckoutError = (errorBody, cart) => {
if (!errorBody) {
return UNKNOWN_ERROR;
}

const errorObject = JSON.parse(errorBody);
const checkoutErrors = errorObject.errors.checkout;
  if (checkoutErrors.line_items) {    
return getLineItemsErrorMessage(checkoutErrors.line_items, cart);
}
return getErrorMessageFromCheckoutObject(checkoutErrors);
};

On Android, we got the JSON error string straight from the SDK:

// Sync the checkout with Shopify    buyClient.createCheckout(checkout, new Callback<Checkout>() {             
    @Override      
public void failure(BuyClientError error) {
promise.reject("", error.getRetrofitErrorBody());
}
});

In theory, we could’ve implemented error handling in native parts of the bridge. But, we would have to duplicate a lot of the business logic. Here’s where React Native and the bridge concept shine. Notice that we did very little on the native side. We passed raw data to the JavaScript realm. That’s all. There we concentrated all our error handling logic in one place.

Closing thoughts

Transformations between native objects and their JavaScript counterparts are not straightforward. There’s very little documentation available on this. For example, we had to dig through React Native’s source code to learn about Writable and Readable maps and arrays.

I hope that this article will shed some light on how to approach this problem. We‘ll find even better ways in the future. If you know any, leave a comment below.

There are a few other points I’d like to highlight before we close the series:

  • Keep the native part of your module thin. Put all the business logic in the JavaScript part. An obvious advantage is code reuse. If you’re a JavaScript developer, this is your home turf. You’ll be able to use all the tricks and tooling that you have years of experience with. Most of the users of your bridge are also JavaScript developers. They’ll have an easier time understanding it.
  • Get to know the SDK and look for methods that’ll make your native code simpler. Use JavaScript to create a single entry-point and enhance the SDKs. Avoid reimplementing things.
  • Consider simplifying the API for your users. For example, the Shopify SDK has several methods for fetching products. We found it convenient to create a single method with optional parameters:
export default {  
...RNShopify,
getProducts: (page = 1, collectionId, tags) => {
if (collectionId) {
return RNShopify.getProductsWithTagsForCollection(page,
collectionId, tags);
}
return tags ?
RNShopify.getProductsWithTags(page, tags)
:
RNShopify.getProductsPage(page);
},

All said and done, building a bridge for native SDKs is a rewarding experience. It also pays big dividends for its creators and users. We‘d like to see React Native bridges for all SDKs soon! Now let’s go and build something.

You can find our React Native Shopify bridge on GitHub. Stay tuned for more stories!

If you like what you read, please tap or click “♥︎” to help to promote this piece to others.


I work at Shoutem where I help creating tools to supercharge your React Native development.