In this article, we examine a few features of React Native that helped us ship a cross-platform iOS and Android app in 8 weeks, without sacrificing user experience on either platform.
First, we’ll see how code reuse and a fast feedback cycle let us move really quickly and help us rapidly iterate on our designs. Then, we’ll learn about the framework’s flexbox-based layout system and evaluate a few approaches to platform-targeting, for situations where it isn’t practical to reuse code due to varying design patterns or platform constraints.
Shared data layer
It’s important not to overlook the data layer of our application as we consider different implementation approaches. One of the biggest benefits of React Native is the unofficial bundling of Redux as a framework for managing and tracking changes in application state. This lets us write one shared data layer that can be used within both our iOS and Android apps, saving us a lot of time and helping us reduce the potential points of failure by only having to maintain our data fetching and state management in one place. This data layer is probably the most critical part of an application to be robust and tested, and having to build that just once helps us ensure its stability across platforms.
Beyond that, Redux is also just a sensible approach to managing application state. Planning and implementing a new feature is easy because Redux clearly defines where many of the necessary pieces of functionality should live. Thus, we can avoid the need for lots of upfront planning and naturally fall into certain conventions and patterns of data fetching and state management. These patterns also serve to ensure the code base is intuitive as we onboard or hand off the project to other developers.
Snappy feedback cycle
We’ve been quite impressed by how quickly the framework allows us to iterate on mobile UI. This is due in large part to features like hot- and live- reloading, where the application UI is essentially “refreshed” (like a web page) whenever we change files. No more waiting to re-build and deploy our app in order to preview a one-line UI change: we get the immediate feedback loop that we previously only saw when working with web or hybrid apps.
At the same time, these mobile experiences feel truly native, because they are.
Native styling
React Native’s generic, flexbox-based layout system allows us to write a single component that can be run on both iOS and Android, using plain-old JavaScript objects to define styles. That means we’re able to avoid the need to implement UI separately using two vastly different languages and layout systems. I’ve found this method of styling and layout to be more intuitive than Auto Layout, and it should feel familiar to folks with a background building for the web. That means it’s easy for designers to hop into the codebase and implement their own designs, helping ensure they come out as intended.
This is great because it saves us time, but we should be careful not to strive for complete design parity across iOS and Android. After all, these are two different platforms with varying design patterns and visual direction.
Users of each platform simply have different expectations when it comes to mobile interfaces and experiences. We want to ensure that every user is served the most familiar, intuitive experience for their platform, and that means implementing platform-specific design and styling in our UI components.
Platform targeting
There are two primary approaches to writing platform-specific code, and we can take advantage of each in interesting ways. Let’s take a look at them now.
The Platform module
React Native exposes the Platform
module, which detects the platform on which
the app is currently running. It exposes a property called OS
which allows us
to control the flow of our application based on the current device platform. As
an example, let’s suppose we want to tell the user which company made the OS
they’re running, just in case they’re not sure. We can do something like this:
import { Platform } from 'react-native'
if (Platfom.OS === 'android') {
alert('Your OS is made by Google')
} else {
alert('Your OS is made by Apple')
}
This works pretty well but there’s still a bit of duplication we’d like to
avoid. Sometimes we may need to diverge our application flow by platform in
order to perform the appropriate action, but often it’s as simple as swapping
some values around based on the current platform. In that case, we can simplify
the code above using a nifty Platform
method called select
. It takes an
object whose keys correspond to the available platforms and returns the
appropriate value for the current platform. Let’s see how that looks:
import { Platform } from 'react-native'
const company = Platform.select({
ios: 'Apple',
android: 'Google',
})
alert(`Your OS is made by ${company}`)
This method can accept any value, including functions, so we can do some pretty powerful stuff with it. It’s easy to see how this approach can be valuable for supplying different appearance values based on the platform or in any situation where the difference in implementation between the two platforms is rather small.
I want to emphasize that last point. We should strive for as much code-reuse as
is realistically possible, but not at the expense of clarity to the developer
or to the user. The goal here is to write programs that are easy to maintain and
work well. Littering our components with too many Platform
expressions and
conditionals is antithetical to that goal because those divergences will
inevitably make the program more difficult to understand (and thus maintain).
So what do we do when our implementations are very different for each platform? Fortunately, React Native’s packager has a clever solution to that, which comes in the form of platform-specific file extensions.
Platform-specific extensions
When we write a statement like import Button from './button'
, the packager
looks in the current directory for either a file called button.js
(or a
directory called button/
containing an index.js
) and bundles it
appropriately. Platform-specific extensions allow us to write a separate file
for each platform, e.g. button.ios.js
and button.android.js
. The packager
will then bundle the appropriate file that corresponds to the packager’s current
target platform.
├── index.js // cross-platform
├── button.ios.js // iOS-specific
└── button.android.js // Android-specific
Let’s see how we can take advantage of this to build a generic component that
requires different, platform-specific implementations. Let’s suppose no cross-
platform <Switch>
component exists, and we want to write our own:
// switch.ios.js
import React, { Component } from 'react'
import { SwitchIOS } from 'react-native'
const Switch = ({ disabled, onChange, style, value }) => (
<SwitchIOS
disabled={disabled}
onValueChange={onChange}
style={style}
value={value}
/>
)
export default Switch
// switch.android.js
import React, { Component } from 'react'
import { SwitchAndroid } from 'react-native'
const Switch = ({ disabled, onChange, style, value }) => (
<SwitchAndroid
disabled={disabled}
onValueChange={onChange}
style={style}
value={value}
/>
)
export default Switch
This is a contrived example, but we may encounter situations which require
vastly different implementations, especially when working with native modules.
How about another example. Let’s suppose now that we want to write a
<SwitchField>
which will contain a switch and a label. Now that we have a
generic <Switch>
, we can probably stick to one generic implementation, but we
still want platform-specific styles. We’ll ultimately have three files:
├── switch-field.js // cross-platform component
├── switch-field-style.ios.js // iOS-specific styles
└── switch-field-style.android.js // Android-specific styles
// switch-field.js
import React, { Component } from 'react'
import { Text, View } from 'react-native'
import Switch from './switch'
import styles from './switch-field-style' // Imports platform-specific style
const SwitchField = ({ style, label, onChange, value }) => (
<View style={[styles.container, style]}>
<Text style={styles.label}>{label}</Text>
<Switch style={styles.switch} onChange={onChange} value={value} />
</View>
)
export default SwitchField
Now we have our generic <SwitchField>
, we’d like to specialize the style with
two independent switch-field-style
files. Let’s see how that might look:
// switch-field-style.ios.js
import { StyleSheet } from 'react-native'
export default StyleSheet.create({
container: {
alignItems: 'center',
alignSelf: 'stretch',
flex: 0,
flexDirection: 'row',
justifyContent: 'space-between',
},
label: {
alignSelf: 'flex-start',
fontSize: 14,
},
switch: {
alignSelf: 'flex-end',
},
})
// switch-field-style.android.js
import { StyleSheet } from 'react-native'
export default StyleSheet.create({
container: {
alignItems: 'flex-start',
flex: 0,
flexDirection: 'column',
},
label: {
fontSize: 14,
marginBottom: 10,
},
switch: {
marginBottom: 10,
},
})
As we have it, our version of <SwitchField>
on Android will show a stacked
label and switch control, but on iOS we see a label on the left and a switch
control on the right.
This is great, because we can keep our component logic in one place but maintain
our platform styles independently. If we decide to make a change to the way our
<SwitchField>
looks on Android, we can be sure it won’t affect its appearance
on iOS.
It’s also worth noting that, while these examples pertain to Components, both
approaches are by no means component-specific! You can use either of these
solutions (the Platform
module or platform-specific file extensions) in any
module within your React Native application.
In the end, React Native has been great for building cross-platform apps but we still must be careful about how we approach cross-platform functionality and styling. Hopefully the examples help you understand the differences between these approaches so you can make the right decision when you find yourself needing to target a particular platform in your React Native apps.
Thanks for reading!