A Real Life Example of Sharing React Web and React Native Components

17 July 2019

Sharing components and business logic between web, iOS and Android can be seen as the holy grail when building a web app and native app using React and React Native. Write once, deploy everywhere. In this post I'll walk through how I set it up and have learned.

This is part 1 of what will (probably) be a series of posts about shared components.

This post is broken into multiple parts:

  1. Background
  2. Goals
  3. Result
  4. How it works
  5. Conclusion

Background

A lot has been said about sharing components between React web and React Native. I find this video of Leland Richardson (then at Airbnb) very instructive.

At Coast (SF startup in stealth) the tech stack was in a hard-to-maintain state with a React web app (desktop and mobile) and a React Native Android and iOS. The logic and UI was the same, but code had basically been copy-pasted between the platforms. This made it hard to maintain the code and slow to iterate on new features. To enable the dev team to move faster, we decided to investigate sharing more code between the platforms.

Goals

Result

Here's an example implementation of the new chat input:

And the code:

import {
    View, IconButton, AutosizeTextInput,
    LoadingIndicator, AnimationBounceIn,
} from '@shared/components';
import { Colors } from '@shared/styles';

const ChatInputBar: React.SFC = ({
    placeholder, value, onChangeText, onSend, onPlusPress,
}) => (
    <View row padding="tiny">
        <IconButton
            source="plus.png"
            color={Colors.blue.flashy}
            onPress={onPlusPress}
        />

        <AutosizeTextInput
            value={value}
            placeholder={placeholder}
            onSubmitEditing={onSend}
            onChangeText={onChangeText}
        />

        {!!value && (
            <AnimationBounceIn>
                <IconButton
                    onPress={onSend}
                    source="send.png"
                    color={Colors.blue.flashy}
                />
            </AnimationBounceIn>
        )}
    </View>
);

What's happening here?

We have a growing set of very basic components (View, IconButton etc). Using these, we can quickly combine them to create more complex components. All of this behaves the same way on web, iOS and Android. So no more copy-pasting!

For instance, View looks something like below. It basically just wraps React Native's View with a lot of short-hand properties that make developing a lot faster. Heavily inspired by Semantic UI React.

import * as React from 'react';
import { View as RNView, ViewProperties as RNViewProperties } from 'react-native';

const ViewPadding = {
    none: 0,
    tiny: 5,
    small: 10,
    normal: 20,
    large: 40,
}

interface ViewProps extends RNViewProperties {
    // Flex direction row makes children align in rows
    row?: boolean;
    // Make the view match its parent's width/height
    fill?: boolean;
    // Center the children
    center?: boolean;
    // Easy to use props to give views standardized paddings
    padding?: 'none' | 'tiny' | 'small' | 'normal' | 'large';
}

const View: React.SFC<ViewProps> = ({
    row, fill, padding, style, ...viewProperties
}) => (
    <RNView
        style={[
            !!props.row && { flexDirection: 'row' },
            !!props.center && { alignItems: 'center', justifyContent: 'center' },
            !!props.padding && ViewPadding[props.padding],
            style,
        ]}
        {...viewProperties}
    />
);

Next, we will look at how this works under the hood.

How it works

Web

We use react-native-web to use web components that behave like React Native's View, Image, Text etc. On web, we hook this up with Webpack. This is what webpack.config.js looks like:

module.exports = {
    resolve: {
        alias: {
            // Our shared components are currently in a sibling
            // folder to the web project. They are written in
            // Typescript, so we link to the build output folder.
            '@shared': path.join(__dirname, '../shared/build'),

            // This alias maps react-native imports to
            // react-native-web. So in the code we import
            // react-native like native.
            'react-native': 'react-native-web',
        },
        // When we do web/native specific implementation, we name
        // files "index.native.js" and "index.web.js". Here we tell
        // webpack to first try to resolve the web specific
        // implementation, and otherwise use the general js file.
        extensions: ['.web.js', '.js'],
        modules: [
            // Note: we point to web's node_modules specifically so that we
            // don't use eg shared's node_modules.
            'node_modules',
        ],
    },
    ...
};

Native

On native, we use Babel to resolve @shared/components:

{
    "plugins": [
        ["module-resolver", {
            "alias": {
                "@shared/components": "../shared/build"
            }
        }],
        ...
    ]
}

Note: React Native's Metro bundler is not happy when trying to require modules outside of the root directory. Using rn-cli.config.js we tell Metro to use multiple directories as its root directory. For reference, se this Github issue.

module.exports = {
    getProjectRoots: () => [
        __dirname,
        `${__dirname}/../shared/build`,
    ],
}

An example of platform specific components

For smaller platform specific logic (eg styling), we usually use React Native's Platform:

import { Platform, Dimensions } from 'react-native';
// if you think tertiary operators are cool:
const screenHeight = Platform.OS === 'web' ? window.innerHeight : Dimensions.get('screen').height;
// if you like readable code:
const screenWidth = Platform.select({
    web: window.innerWidth,
    native: Dimensions.get('screen').width;
});

But where logic varies a lot - for instance UI which uses native modules - we have separate files:

datepicker.web.tsx

import * as React from 'react';
import DatePicker from 'react-datepicker';

const DatePicker: React.SFC = ({
    minDate, maxDate, onChange, onBlur, children, visible,
}) => (
    <DatePicker
        visible={visible}
        minDate={minDate} maxDate={maxDate}
        onChange={onChange}
        onClickOutside={onBlur}
        customInput={children}
    />
);

datepicker.ios.tsx

import { View, ModalFromBottom } from '@shared/components';
import { DatePickerIOS } from 'react-native';

const DatePicker: React.SFC = ({
    minDate, maxDate, onChange, onBlur, children, visible,
}) => (
    <View>
        {children}
        // our own modal animating up from the bottom when visible is true
        <ModalFromBottom visible={visible}>
            <DatePickerIOS
                onDateChange={onChange}
                minimumDate={minDate}
                maximumDate={maxDate}
            />
        </ModalFromBottom>
    </View>
);

datepicker.android.tsx

import { View } from '@shared/components';
import DateTimePicker from 'react-native-modal-datetime-picker';

const DatePicker: React.SFC = ({
    minDate, maxDate, onChange, onBlur, children, visible,
}) => (
    <View>
        {children}
        <DateTimePicker
            mode="datetime" isVisible={visible}
            onConfirm={onChange} onCancel={onBlur}
            minimumDate={minDate} maximumDate={maxDate}
        />
    </View>
);

Conclusion

So far, the shared components setup has served us very well. For us, with React web and React Native as our stack, the upside in speed definitively outweighs the potential downsides*. So if you're bootstrapping and want to move quickly, this is probably a good way to go. As we learn more about shared components, I will probably write a followup to talk about the caveats and things I'm not seeing yet 😬

* mainly compromises due to sharing a lot of code, dirtier HTML DOM due to react-native-web etc