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:
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
- Write once, run anywhere (or at least make it easy to customize platform specific differences)
- Keep client side business logic the same (and use the time spent on copy-pasting to write tests)
- Make styling more consistent between platforms
Result
- ~85% of code can be reused, which speeds up development by a lot
- Business logic is only written once, and we spend 50% of the saved time on writing proper unit and integration tests
- Styling is up to date with the latest brand work and will be less time-consuming to maintain than before
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