Tech Thoughts
technical

Porting Practice to Web: react-native-web in Production

Learn how DataCamp ported their mobile application to the web using react-native-web

This article is part of a short series in which DataCamp’s Practice & Mobile Team describe the process of porting a mobile application to the web. In this second article, we go in depth on our experiences of working with react-native-web on a production application.

In the first article of this series we described how our journey began with a proof-of-concept of the mobile app running in the browser. Getting this up and running involved following the official documentation for react-native-web. This involves setting up Webpack to compile a new web target from the mobile source code, and aliasing react-native so that it uses the react-native-web implementation instead. So far so good. However, we soon stumbled into another problem: the mobile source code makes references to ‘native modules’ which do not have web counterparts.

Bridging The Gap

A native module is a bit of native code that is compiled into your app’s binary and then bridged with JS to allow interop with React Native.

In our case we had multiple such modules that needed to addressing. One such example is react-native-linear-gradient: a simple library that allows the user to render linear gradients, which is not provided by React Native core.

We had a couple of options to solve this problem:

  1. Branch the code so that it selectively used the native module, inline in the source code.
  2. Creating a wrapper component that abstracted this branching away
  3. Aliasing the native module so that no branching is required

In our case we opted for option 3 as Webpack already provided a convenient way to alias modules (such as react-native itself), and this also requires less change to the application source code itself.

Setting up a module alias in Webpack is simple:

module.exports = {
  // ...
  resolve: {
    // ...
    alias: {
      "@datacamp/react-native-linear-gradient": path.resolve(
        __dirname,
        "./mocked_modules/react-native-linear-gradient"
      )
    }
  }
};

And our mocked module implementation:

export default class LinearGradient extends React.PureComponent<Props> {
  render() {
    // See gist for full implementation
    // https://bit.ly/2X2yJzL
    // ...
  }
}

We repeated this process for the remaining native modules in the project. Some of them required a genuine implementation as above, whereas for others it was sufficient just to stub out the bits of the API we used and effectively doing a no-op. This was useful for modules such as react-native-code-push which we had no requirement to support for the web target:

import * as React from "react";

const codePush = (App: React.Node) => App;

codePush.getUpdateMetaData = () => {
  return Promise.resolve(null);
};

export default codePush;

With the proof-of-concept now up and running, we turned our attention to the task of turning it into a real production application. The main task at hand was optimizing the mobile screens to be better suited for display and interaction on the web.

The dream of React Native Web is that you can reuse your mobile application’s source code on the web. As soon as you start to rewrite logic and components, you start to reduce the amount of reuse and thus the benefit of using the same codebase is diminished. In order to maximize the amount of reuse we achieved we devised a couple of strategies to aid us in our journey.

Strategies for Code Reuse

Whilst the overall designs had been optimized for web, many of the components that appeared on the screen could be reused between web and mobile. In many cases, we required only very trivial changes to the component in order to adjust the style for web.

Branching Within Components

In such cases we opted for a very simple solution of basic conditional branching within our components. For instance, to conditionally apply a style we can simply check the current operating system using Platform.OS:

const marginStyle = {
  marginTop: Platform.OS === "android" ? 60 : 19,
  marginBottom: Platform.OS === "android" ? 60 : 0
};

However, rather than using ternary statements we preferred the declarative approach of Platform.select:

const someJsx = (
  <SomeComponent
    style={Platform.select({
      android: styles.androidStyle,
      ios: styles.iosStyle
    })}
  />
);

A limitation that we found with Platform.select is that it is mainly intended for switching behaviors between iOS and Android. So, in cases where we want to branch on web and mobile but not discriminate between iOS and Android, we found ourselves having to repeat ourselves:

const style = Platform.select({
  android: styles.mobileStyle,
  ios: styles.mobileStyle,
  web: styles.webStyle
});

To solve this problem, we implemented our own variation of Platform.select, that we call selectOnPlatform. This allows us to discriminate between mobile and web but easily fallback to different Android and iOS behavior too if you wish.

// Mobile and web only
const style = selectOnPlatform({
  mobile: styles.mobileStyle,
  web: styles.webStyle
});

// Alternatively, analogous to Platform.select
const style = selectOnPlatform({
  android: styles.androidStyle,
  ios: styles.iosStyle,
  web: styles.webStyle
});

The full implementation of selectOnPlatform is available here.

Component Branching

Sometimes a component would require many changes to work correctly on each platform, and so a different strategy was required.

Instead of adding many conditionals into a single component file, we opted to branch at the component level using platform-specific files. We came up with a simple heuristic to decide when to split into platform-specific components: if a component required more than 2-3 branches then it was typically a viable candidate for refactoring into platform-specific files.

Creating platform-specific components is simple: if you have a component called MyComponent.js, you can create a new version of it (ideally with the same public interface) called MyComponent.web.js. Then make sure you have the correct resolve configuration in your Webpack config:

module.exports = {
  // ...
  resolve: {
    // ...
    // Prefer .web.js over .js files
    extensions: [".web.js", ".js"]
  }
};

The web version of your application will now resolve to MyComponent.web.js and the mobile version will not even be included in the compiled web bundle.

This technique was invaluable for solving some of the hierarchical differences in the way components are composed between web and mobile: on both platforms the app features a FeedbackView which displays feedback to the user based on whether they answered the question correctly or not. On mobile this is a ‘floating view’ that appears over the top of the activity, whereas on web this appears in the bottom bar.

Practice Component Hierarchy on Web
Practice Component Hierarchy on Web 🖥️

In both cases, the root component here is a LearningScreen. This component is the same for both web and mobile. However, several of the sub-components you see have platform-specific implementations: ExerciseTopBar, ExerciseBottomBar, ExerciseBody and FeedbackView. These have been branched due to major differences in layout between the web and mobile versions, but many of the components they use internally are still shared 1:1 across platforms.

Practice Component Hierarchy on Mobile
Practice Component Hierarchy on Mobile 📱

This also allows us to solve the differences in view hierarchy: the web version of FeedbackView is rendered inside ExerciseBottomBar, whereas on the mobile version it’s rendered inside Activity.

Connecting it all together

Sometimes we found it beneficial to branch at the screen level, for situations where the differences between a screen layout were large, or multiple screens shared the same props and bound action creators. However, there is still opportunities for code reuse at this level: in our app we use Redux as our state container. Screens are connected to the redux store using react-redux and connect calls. Connect calls are curried and can be partially applied: we found this useful for sharing selector and bound action creators between screens that had a different layout but the same or very similar functionality.

Here’s an example where we partially apply the connect call:

// connectAfterWorkoutScreen.js
export default connect(
  mapStateToProps, // Map state to props for both screens
  mapDispatchToProps // Map dispatch to props for both screens
);

Then we can just import this and use it as needed, binding to each component as necessary:

// AfterWorkoutScreen.web.js
// AfterWorkoutScreen.js
import connectAfterWorkoutScreen from './connectAfterWorkoutScreen'

class AfterWorkoutScreen extends React.PureComponent {
  // ...
}

// Re-use the connect call
default default connectAfterWorkoutScreen(AfterWorkoutScreen)

Go with the Flow

Another technique we found useful when paired with the component branching above was to share flow-types between the components. Ideally, platform-specific components will still have the same public interface (exposed via it’s props) so that other components can consume them in a platform-agnostic way, without needing to know which implementation is being used.

Rather than duplicating the types between the components, we found it useful to import the types from the original mobile component:

// ExerciseTopBar.web.js

// Import the props from the mobile implementation
// The actual JS will not be included in the web bundle
import type { Props } from "app/components/ExerciseTopBar.js";

This allows us to easily share the types between the different components without any duplication.

Next Steps

The combination of the above techniques allowed us to do most of the work to port our mobile application to web. However, we still had some way to go before we could consider our app to be production-ready: during the port we encountered some functional differences between mobile and web and the output JS bundle size was enormous - far too big to ship to the browser.

Stay tuned for part 3 to see how we brought everything together into the final shipped version! Interested in working with DataCamp? Check out our careers page!