React Native makes it very easy to include platform-specific code in a project. But what if we only need to customize a few styles?

Background

React Native allows us to build mobile apps that work on multiple platforms. Support for iOS and Android is built in, but there are packages that provide support for other platforms, such as react-native-windows.

In most React Native projects, the bulk of the code is generic and “just works” on all supported platforms. But occasionally, it is necessary to have some platform-specific variation.

React Native has really nice support for this. When we have a line of code like import MyComponent from './MyComponent', the React Native packager will look for ./MyComponent.js and ./MyComponent/index.js, just like node.js does.

But it will also look for files named ./MyComponent.<platform>.js and ./MyComponent/index.<platform>.js where <platform> is whichever platform is being targeted (ios, android, etc.).

With this behavior, the importing code doesn’t need to know that a particular component has a platform-specific variant. The React Native packager hides that from the rest of the code.

Example

Let’s say we have an InputField component in our project that wraps the built-in TextField and styles it according to our design. We’ll start with an iOS-only variant and use the instructions provided in the docs to give our input a bottom border:

InputField
import React from 'react'
import { StyleSheet, TextInput } from 'react-native'
import { Color, FontFamily, FontSize } from 'theme'
export default function InputField({ style, ...props }) {
return (
<View style={styles.container}>
<TextInput style={[styles.input, style]} {...props} />
</View>
)
}
const styles = StyleSheet.create({
container: {
borderBottomColor: Color.inputFieldBorder,
borderBottomWidth: 1
},
input: {
color: Color.textLightActive,
flex: 1,
fontFamily: FontFamily.default,
fontSize: FontSize.default,
height: 21,
marginHorizontal: 10,
marginBottom: 4
}
})

Note that we’re allowing the user of this component to pass in a style and we’re merging it with our own local styles. If you’re not familiar with the syntax here, we’re using not-yet-standardized object rest/spread properties and JSX spread attributes.

What About Android?

When we run our app on Android, we find that our InputField doesn’t look good at all. It has two bottom borders, and you can’t see any text in the field.

The problem is that Android TextFields supply their own bottom border and some padding, so they need to be much taller in order to show the text. And we don’t need to supply our own bottom border in the wrapper View.

How do we accomplish this?

Option 1: Make an Android-Specific Component

One option would be to make an Android-specific version of the InputField component. We could rename InputField.js to InputField.ios.js and then add InputField.android.js.

The problem with this is that there would be a lot of duplication, and any time we need to change the component, we have to remember to do it in both files. This isn’t ideal.

Option 2: Make Android-Specific Styles

This is a better option. We can keep the component itself the same, but have platform-specific stylesheets.

How do we do that?

Most React Native examples have the styles embedded in the same file as the components. But we don’t have to do it that way. We can extract the styles to their own file and import them into our component:

Importing Styles
import React from 'react'
import { TextInput } from 'react-native'
import styles from './styles'
export default function InputField({ style, ...props }) {
return (
<View style={styles.container}>
<TextInput style={[styles.input, style]} {...props} />
</View>
)
}

We can then use the React Native packager to have iOS- and Android-specific stylesheets:

styles.ios.js
import { StyleSheet } from 'react-native'
import { Color, FontFamily, FontSize } from 'theme'
export default StyleSheet.create({
container: {
borderBottomColor: Color.inputFieldBorder,
borderBottomWidth: 1
},
input: {
color: Color.textLightActive,
flex: 1,
fontFamily: FontFamily.default,
fontSize: FontSize.default,
height: 21,
marginHorizontal: 10,
marginBottom: 4
}
})
styles.android.js
import { StyleSheet, TextInput } from 'react-native'
import { Color, FontFamily, FontSize } from 'theme'
export default StyleSheet.create({
container: {
},
input: {
color: Color.textLightActive,
flex: 1,
fontFamily: FontFamily.default,
fontSize: FontSize.default,
height: 50,
marginHorizontal: 10,
marginBottom: 4
}
})

This is not a bad solution. The component itself doesn’t know anything about platform-specific styles. It just imports from styles and the packager handles the platform-specific stuff.

However, there’s still a fair bit of duplication here and it’s hard to tell at a glance what’s different between the two files.

We can always compare them to find the differences. But when we come back to the code in 3 months, will we remember which differences are intentional, and which might be the result of an earlier oversight?

What if we had modified the styles in one of the files but forgot the other?

Can we make this a bit better?

Option 3: Extract the Platform Differences

Rather than having two completely separate stylesheets, what if we only put the differences in separate files?

The trick is to notice that we’re constructing the StyleSheet with a plain-old JavaScript object. We can do some manipulation on that object before we pass it to StyleSheet.create. Here’s what that might look like:

styles.js
import { merge, mergeWith } from 'ramda'
import { StyleSheet } from 'react-native'
import styleOverrides from './styleOverrides'
import { Color, FontFamily, FontSize } from 'theme'
const baseStyles = {
container: {
},
input: {
color: Color.textLightActive,
flex: 1,
fontFamily: FontFamily.default,
fontSize: FontSize.default,
marginHorizontal: 10,
marginBottom: 4
}
}
const styles = mergeWith(merge, baseStyles, styleOverrides)
export default StyleSheet.create(styles)
styleOverrides.ios.js
import { Color } from 'styles/theme'
export default {
container: {
borderBottomColor: Color.inputFieldBorder,
borderBottomWidth: 1
},
input: {
height: 21
}
}
stylesOverrides.android.js
export default {
input: {
height: 50
}
}

With this solution, we can easily see which styles are common and which styles are customized for a given platform. When we need to modify the base styles, we only have to do it in one place, and we don’t have to worry about accidentally forgetting to update the other platform’s stylesheet.

The downside is that we can’t see all of the styles at a glance - we have to look at two or three different files. For that reason, you may prefer to stick with separate stylesheets for each platform.

There are a few things to note about this implementation:

  • The styleOverrides files are exporting plain-old JavaScript objects, not StyleSheets.

  • styles.js defines its baseStyles as a normal object as well.

  • The baseStyles and styleOverrides are merged together and only then passed to StyleSheet.create.

The merge is the tricky part. We can’t use something like Object.assign({}, baseStyles, styleOverrides) here, because that only merges keys at the top level. The input styles from styleOverrides would completely replace the input styles from baseStyles. That’s not what we want.

We need to perform the merge one level down instead, so that baseStyles.input is merged with styleOverrides.input.

In the code above, I’ve used Ramda to do the job using mergeWith(merge, baseStyles, styleOverrides). This merges baseStyles with styleOverrides. When both objects have the same key, the values of those keys are merged using merge. This is exactly what we want.

An alternative would be to use some kind of deep-merge solution. Lodash’s merge would work for this: _.merge({}, baseStyles, styleOverrides).

Conclusion

React Native provides a nice set of tools for mixing platform-specific code into our projects. Using these tools, we now have a way to tweak a few styles in a component without introducing duplication.