Integrating React Native Into Our Existing Native Apps

At Wealthsimple, our mobile apps are very important to our business. A significant portion of our clients use them regularly to check on their accounts and to add to their savings.

Inspired by posts from AirBnb, Instagram, and Artsy we started exploring React Native back in November 2016 to see if it was something we could add to our development toolbox. Cross-platform code reuse, hot reloading, and CodePush have the potential to significantly increase how fast we can get new features and bug fixes out to our clients. Additionally, the ability to slowly mix this into our app by integrating with our existing code base was appealing.

Our Goals

We had three main questions we wanted to answer before we committed to React Native:

1. Can we increase our development speed?

Releasing frequently is vital to the success of any company. Responding to customer feedback, fixing issues, and delivering new features on a regular basis builds trust with clients and ensures that we have their best interests at heart.

2. Does it align our iOS and Android platforms?

Releasing features on Android and iOS at the same time allow us to reduce overhead costs related to sprint planning and QA. Everything becomes easier when we can work on the same features at the same time.

3. Can we maintain a native app feel?

Typically there's a very clear distinction between native apps and other cross-platform solutions. Clients can tell which parts are native and which are not. It was important for us to ensure we maintain a level of quality and smoothness that our clients expect.

Everything we've read about React Native suggested this was all possible. It was time for us to give it a try and see if it worked for us.

Our Strategy

Most of the references online that cite React Native show tremendous success when it's used in the context of simple applications or for simple tasks. With that in mind, we wanted to see if it could stand up to a more complex feature. We wanted something that was non-trivial and included some animations. If we could find a flow that was isolated from the rest of our app it would allow us to get our hands dirty while limiting the complexity of the integration with our existing codebases.

As part of our new onboarding flow, we started to implement a new interface for our risk score survey. This feature was isolated from the rest of our app as a single entry point and callback when the flow was complete - the perfect opportunity to test out how React Native handles transitions and communication with native code. Once we were happy with the result, we moved on to interlacing React Native screens with native screens.

Image showing new onboarding

Biggest Challenge - Navigation and Communication with Native Code

Our biggest challenge we ran into was how to communicate and navigate seamlessly with our existing native code base.

Building UI components in React Native has been a dream. Screens come together quickly across both platforms and React enables us to build a component library that we can leverage easily going forward. The challenge was making these screens work together. Navigation has had a bumpy road in React Native's short life - the framework itself is on its fourth solution. Since our onboarding feature was a distinct flow on its own, we were able to experiment with a few different navigation options. We tried Navigator, NavigatorExperimental and the latest recommended option: ReactNavigation. Out of all of these options, ReactNavigation proved to be the best option for our isolated flow.

However, the JavaScript implementations of navigation have some significant drawbacks. If you look closely, you can see that the transitions between screens are not quite the same as those in the native transitions. Additionally, these options simply didn't work when we wanted to transition from a native screen to a React Native screen when inside the same navigation context, such as a UINavigationController on iOS.

Inspired by the AirBnb native-navigation library, we tried implementing our own approach to navigating between native and React Native screens. The end result turned out much simpler than we initially imagined, by leveraging Native Modules and Event Emitters.

Our app has two different scenarios: going from a React Native screen to a native screen and going from a React Native screen to another React Native screen.

Image showing Dashboard to Account Detail to Activity

React Native to Native

This case proved to be the simplest. We create specific listeners for different areas of the React Native app that expose functions that JavaScript can call directly. In the case of our Account Detail screen, we have methods that can handle specific actions, like a user tapping on their Activities.

In android we have an AccountModule that simply creates the Activities screen and starts it.

    @ReactMethod
    public void showActivities(final String accountId) {
        handler.post(new Runnable() {
            @Override
            public void run() {
                Activity activity = getReactApplicationContext().getCurrentActivity();
                Intent intent = new Intent(activity, ActivitiesListActivity.class);
                intent.putExtra(PROP_ACCOUNT_ID, accountId);
                activity.startActivity(intent);
            }
        });
    }

In iOS, we take the response and notify our coordinator which simply transitions to the new view controller.

// AccountDetailListener.m
RCT_EXPORT_METHOD(showActivities : (NSString *)accountId) {  
    dispatch_async(dispatch_get_main_queue(), ^{
        NSDictionary *info = @{ @"accountId" : accountId };
        [[NSNotificationCenter defaultCenter] postNotificationName:@"SHOW_ACTIVITIES" object:self userInfo:info];
    });
}

// AccountCoordinator.swift
func subscribeToEvents() {  
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(showActivitiesPage),
        name: NSNotification.Name(rawValue: "SHOW_ACTIVITIES"),
        object: nil
    )
}

@objc func showActivitiesPage(notification: Notification) {
    container.transition(to: activityViewController)
}

React Native to React Native

For this use case, we need a way for a React Native screen to tell the native coordinator what React Native container components to push onto the stack next. A simple routes definition file in React Native allows us to map Routes to Components:

// Routes.js
export default {  
  EarnCredit: {
    screen: EarnCredit,
    title: 'Money managed free',
  },
  AccountHoldings: {
    screen: AccountHoldings,
    title: 'Money managed free',
  },
}

Next up, we create a native module that we can call from React Native:

// navigators/native.js
export const push = (route: any, coordinator: any) => {  
  const routeInfo = Routes[route];
  NativeModules.ReactNavigationModule.push(route, coordinator, routeInfo.title, {});
};

Our Native Module implements this function in Android, by simply creating a new Activity and starting it. In iOS, it's a little bit more difficult based on the way that iOS Native Modules are instantiated by React Native and the different navigation architecture in iOS.

In Android, this function simply creates a new Activity and passes along the parameters:

// ReactNavigationModule.java
    @ReactMethod
    public void push(final String screenName, final String coordinatorName, final String title, final ReadableMap props) {
        handler.post(new Runnable() {
            @Override
            public void run() {
                Activity activity = getCurrentActivity();
                Intent intent = GeneralReactActivity.createIntent(activity, screenName, title, props);
                activity.startActivity(intent);
            }
        });
    }

In iOS we post an NSNotification using the coordinator identifier as a context key. Because we use a native UITabBarController, it's possible we could have multiple React Native coordinators in memory at the same time. The identifier allows us to determine the context of the push request.

// ReactNavigationModule.m

RCT_EXPORT_METHOD(push  
                  : (NSString *)screenName coordinator
                  : (NSString *)identifier title
                  : (NSString *)title props
                  : (NSDictionary *)props) {

    dispatch_async(dispatch_get_main_queue(), ^{
        NSDictionary *info = @{
            @"screenName" : screenName,
            @"screenTitle" : title ?: @"",
            @"identifier" : identifier,
            @"screenProps" : props ?: @{}
        };
        [[NSNotificationCenter defaultCenter] postNotificationName:@"NAVIGATOR_PUSH" object:nil userInfo:info];
    });

Our React Coordinator listens for this notification and parses the request; then it creates a new view controller and pushes it onto the navigation stack:

// ReactNavigationCoordinator.swift
fileprivate extension ReactNavigationCoordinator {  
    func subscribeToNotifications() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(push),
            name: NSNotification.Name(rawValue: "NAVIGATOR_PUSH"),
            object: nil
        )
    }

    @objc func push(notification: Notification) {
        guard let info = notification.userInfo as? ReactProps,
            let identifier = info["identifier"] as? String,
            let screenName = info["screenName"] as? String,
            identifier == self.identifier else { return }

        let screenTitle = info["screenTitle"] as? String

        var props = reactProps
        props["screenProps"] = info["screenProps"] as? ReactProps ?? [:]

        let viewController = loadScreen(name: screenName, title: screenTitle, props: props)
        container.transition(to: viewController)
    }

    func loadScreen(name: String, title: String?, props: ReactProps) -> UIViewController {
        let viewController = ReactViewController(screenName: name, coordinator: self, props: props)
        viewController.title = title
        return viewController
    }
}

This final native piece is a general ReactViewController and ReactActivity that can accept generic values, including the screenName, and pass those back into the React Native bridge.

class ReactViewController: UIViewController {  
    private var screenName: String!
    private var props: ReactProps?
    weak var coordinator: ReactViewControllerCoordinator?

    var screen: String {
        return screenName
    }

    convenience init(screenName: String, coordinator: ReactViewControllerCoordinator, props: ReactProps? = nil) {
        self.init(nibName: nil, bundle: nil)

        self.screenName = screenName
        self.coordinator = coordinator
        self.props = props
    }

    override func loadView() {
        return RCTRootView(bridge: bridge, moduleName: "Wealthsimple", initialProperties: viewProps())
    }

    private func viewProps() -> ReactProps {
        var props = self.props ?? [:]
        props["coordinator"] = coordinator?.identifier ?? ""
        props["initialScreen"] = screenName
        return props
    }
}

Back in JavaScript, our root component of our app can check the initialProps of the screen and render the appropriate container component:

render() {  
  const Route = Routes[this.props.initialScreen];
  return (
     <Route.screen
       {...this.props.screenProps}
       coordinator={this.props.coordinator}
       navigator={this.props.navigator}
     />
  );
}

Event Emitters

Because we now have a mix of native and React Native screens in our native navigation containers, on iOS, some screens exist inside our UITabBarController. React has lifecycle methods like componentWillMount and componentDidMount but the containing view controller never gets deallocated, so some React components won't receive these callbacks as the user taps between tabs of the app. Instead, the native iOS framework uses the viewWillAppear and viewDidAppear lifecycle methods. In order to let our React component know about these callbacks, we had to write a simple Event Emitter to communicate these events to Javascript:

@interface ReactViewControllerEventEmitter : RCTEventEmitter <RCTBridgeModule>

+ (void)viewControllerDidAppear:(ReactViewController *)viewController;

@end

@implementation ReactViewControllerEventEmitter

RCT_EXPORT_MODULE();

- (void)startObserving {
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(viewControllerDidAppear:)
                                                 name:NavigationEvent
                                               object:nil];
}

- (void)stopObserving {
    [[NSNotificationCenter defaultCenter] removeObserver:self name:NavigationEvent object:nil];
}

+ (void)viewControllerDidAppear:(ReactViewController *)viewController {
    [[NSNotificationCenter defaultCenter] postNotificationName:NavigationEvent object:viewController];
}

- (void)viewControllerDidAppear:(NSNotification *)notification {
    ReactViewController *viewController = notification.object;
    [self sendEventWithName:NavigationEventOnAppear body:@{ @"screenName" : viewController.screen }];
}
@end

Back in our generic ReactViewController, we simply call this hook ourselves:

//ReactViewController.swift
override func viewDidAppear(_ animated: Bool) {  
    super.viewDidAppear(animated)

    ReactViewControllerEventEmitter.viewControllerDidAppear(self)
}

This allows us to listen for these events in our JavaScript components and update appropriately:

  componentWillMount() {
    this.props.fetchData();

    if (ReactViewControllerEventEmitter) {
      this.subscription = eventEmitter.addListener(ON_APPEAR, (event) => {
        if (event.screenName === SCREEN_NAME) {
          this.props.fetchData();
        }
      });
    }
  }

This approach has proven very successful in allowing us to seamlessly transition into, out of, and between React Native screens using the native transition paradigms. We still leverage the benefits of React Native to create our UI components very quickly while maintaining a native feel and seamless interaction with our existing native code base.

Other Tips

Some other helpful hints that we found useful:

  1. Static typing
    We currently use Flow for static types in Javascript. Flow allows us to leverage all the advantages of static typing that our native developers are used to thereby easing the transition from static typed languages like Swift and Java.

  2. React Storybook
    React Storybook is a great way to visualize components and the different states they can be in. This has been a great transition for us from our iOS snapshot testing and helps speed up development. Our designers can also start contributing more directly to our component development as an added bonus.

  3. Native platform knowledge is an important asset
    We're constantly passing data back and forth between the native and the React Native side. Understanding how to fallback to the advantages of native is crucial to ensure a seamless transition to adopting React Native.

  4. Jest for tests
    Tests are important in our engineering culture to ensure features we develop work as expected and continue to work as we push our apps forward. Jest has been a breeze to help us test our reducers, action creators and snapshot our components.

Conclusion

We've only scratched the surface of the potential of React Native. Here's a current breakdown of our existing codebase as of May 2017. You'll notice that Javascript still makes up a very small percentage of our entire codebase.

So far our experience with React Native has been exceedingly positive. We're sharing nearly 100% of our React Native codebase between iOS and Android, and we're shipping features on both platforms at the same time with ease while maintaining the native feel of the app. We're confident a mix of React Native and native code will deliver the best possible apps for our clients going forward.