I recently wrote about why we chose universal components at Major League Soccer and I received a lot of feedback asking about the specifics of how we actually implemented our UCs system.

While it doesn’t make sense for us to open source our MLS specific components, I didn’t want to leave everyone hanging on exactly how we implement UCs so I created an example repository and will walk through setting up your own UC system in this post.

The deployed storybook can be found here.

Choosing A Solution

The very first challenge you will face when implementing UCs is what solution to base your library on. Currently there are two choices for this, react-native-web (RNW) and react-primitives (RP). At Major League Soccer we decided to go with RNW for two reasons:

It’s more mature than react-primitives. This might sound odd since both are technically in early alpha, but RNW currently has better parity with React Native and supports React 16. (https://github.com/airbnb/react-sketchapp/issues/104)

This means that RNW will work out of the box with both create-react-app , create-react-native-app , and react-native init . Examples of both CRA and CRNA can be found in the example repository I mentioned at the beginning of this article. RP uses the Touchable primitive which doesn’t technically map directly to RN. There is a Touchable api in React Native but it’s not a component like TouchableOpacity or any of the other React Native touchables. This requires more complex setup.

Setting Up the Project

We are going to use Lerna to setup a monorepo. This will make managing the deployment of our universal components package (and any packages we may create in the future) easier.

First we need Lerna installed.

yarn add --global lerna

Then we need to create a new folder for our project, and from the root of the project run yarn init to create a new package.json and then lerna init to set up Lerna.

This will create a bare bones setup. You should have a project that looks like this:

packages/

lerna.json

package.json

Let’s add a .gitignore at the root and make sure to ignore all node_modules directories we may end up with.

**/node_modules/**

Now we should have a project structure like this:

packages/

.gitignore

lerna.json

package.json

Next we need to create our universal-components package. Inside the packages directory create a new folder called universal-components . Then cd into that directory and run yarn init again (for this package use the name you want to import components from in your apps).

packages/

universal-components/

package.json

lerna.json

package.json

Now that we have our package ready, we need to setup for universal components. This means we need Babel support for transpiling code for testing/storybook, as well as aliasing for RNW.

First let’s install all needed babel dependencies:

// from within packages/universal-components yarn add -D babel-plugin-module-resolver babel-plugin-transform-class-properties babel-plugin-transform-es2015-modules-commonjs babel-preset-flow babel-preset-react babel-preset-stage-2 flow-bin

That’s a lot of modules! Let’s see what each does:

babel-plugin-module-resolver : used for aliasing and adding .web.js extension support

: used for aliasing and adding extension support babel-plugin-transform-class-properties: used to add support for class properties which are supported in React Native.

used to add support for class properties which are supported in React Native. babel-plugin-transform-es2015-modules-commonjs: used to add support for import/export statements without needing .default

used to add support for import/export statements without needing babel-preset-flow: used to add Flow support

used to add Flow support babel-preset-react: used to add React support

used to add React support babel-preset-stage-2 : used to add things like async/await and rest/spread operators (supported by React Native)

: used to add things like and operators (supported by React Native) flow-bin: used to run Flow against our components

Next we need to create a .babelrc file in our new universal-components package and then add the following config:

{

"plugins": [

"transform-class-properties",

"transform-es2015-modules-commonjs",

[

"module-resolver",

{

"alias": {

"react-native": "react-native-web"

},

"extensions": ["web.js", ".js"]

}

]

],

"presets": ["react", "stage-2", "flow"]

}

Now that we can support universal components we need to install React:

yarn add -D react react-dom react-native-web prop-types

We add them as devDependencies because we don’t want them to install with our universal components. In a native environment we don’t want to install react-native-web and vice versa. So instead we add them as devDepenencies and also include them in the peerDepencencies section of our package.json file. This way when users of the UC package install, they will be warned about needed unmet peer dependencies.

We don’t add react-native as a devDependency because we are using the web version of Storybook and don’t need React Native in this environment.

// in packages/universal-components/package.json "peerDependencies": {

"prop-types": ">=15",

"react": ">=15",

"react-dom": ">=15",

"react-native": ">=0.42",

"react-native-web": ">=0.0.129"

}

The last thing we need to do is create a components directory that will be the future home to our universal components. 🏠

Once that is done your project should look like the following:

packages/

universal-components/

components

.babelrc

package.json

yarn.lock

lerna.json

package.json

Alright! Now we’re ready to develop our first universal component! 🎉

Setting Up Storybook

In order to ensure we keep an organized development process (and to ensure our components work properly on web), we will use Storybook to isolate development and get realtime feedback about how your components look and behave.

First thing we have to do is add storybook as a dependency to our universal-components package.

// from within packages/universal-components yarn add -D @storybook/react

Next we need to create a .storybook directory for our Storybook config:

packages/

universal-components/

.storybook/

components/

.babelrc

package.json

yarn.lock

lerna.json

package.json

Inside of .storybook add a config.js file. This is where our basic Storybook configuration goes. Inside of config.js add the following:

import { configure } from '@storybook/react'; const req = require.context(

'../components/', // path where stories live

true, // recursive?

/\__stories__\/.*.js$/, // story files match this pattern

); function loadStories() {

req.keys().forEach(module => req(module));

} configure(loadStories, module);

The important takeaway is we configure Storybook to look in our components directory for __stories__ directories and load them.

This lets us keep our stories next to the related component which is nice and matches up nicely with default Jest configuration (more on testing in a bit).

Next we have to alter the default Webpack config for Storybook so that we can properly parse our imported universal components. Inside the .storybook directory create a webpack.config.js file and add the following:

const path = require('path');

const webpack = require('webpack');

// use babel-minify due to UglifyJS errors from modern JS syntax

const MinifyPlugin = require('babel-minify-webpack-plugin'); // get default webpack config for @storybook/react

const genDefaultConfig = require('@storybook/react/dist/server/config/defaults/webpack.config.js'); const DEV = process.env.NODE_ENV !== 'production';

const prodPlugins = [

new webpack.DefinePlugin({

'process.env.NODE_ENV': JSON.stringify(

process.env.NODE_ENV || 'development'

),

'process.env.__REACT_NATIVE_DEBUG_ENABLED__': DEV,

}),

new webpack.optimize.OccurrenceOrderPlugin(),

new MinifyPlugin(),

]; module.exports = (baseConfig, env) => {

const config = genDefaultConfig(baseConfig, env);

const defaultPlugins = config.plugins;

const overwrite = {

devtool: 'inline-source-map',

module: {

rules: [

{

test: /\.js$/,

exclude: /node_modules/,

loader: 'babel-loader',

query: { cacheDirectory: true },

},

],

},

plugins: DEV ? defaultPlugins : prodPlugins,

}; return Object.assign(config, overwrite);

};

Now we won’t experience any issues during our production builds of Storybook and are ready to create a component, but first let’s add a few script entries in package.json to make our lives a bit easier.

// in universal-components/package.json "scripts": {

"storybook": "start-storybook -p 9001 -c .storybook",

"build: "build-storybook"

...

},

Let’s create a new directory in components for a Button component:

components/

button/

__stories__/

index.js

index.js

Inside of button/index.js add the following code:

// @flow import React, { Component } from 'react';

import {

Platform,

StyleSheet,

Text,

TouchableOpacity,

View,

} from 'react-native';

import PropTypes from 'prop-types'; export type ButtonProps = {

backgroundColor: string,

fontColor: string,

onPress: () => void,

size: string,

style: StyleSheet.Styles,

text: string,

}; const getButtonPadding = (size: string): number => {

switch (size) {

case 'small':

return 10;

case 'medium':

return 14;

case 'large':

return 18;

default:

return 14;

}

}; const getButtonFontSize = (size: string): number => {

switch (size) {

case 'small':

return 10;

case 'medium':

return 16;

case 'large':

return 20;

default:

return 16;

}

}; export default class Button extends Component<ButtonProps, *> {

static propTypes = {

backgroundColor: PropTypes.string,

fontColor: PropTypes.string,

onPress: PropTypes.func.isRequired,

size: PropTypes.string,

style: PropTypes.oneOfType([

PropTypes.array,

PropTypes.object,

PropTypes.string,

]),

text: PropTypes.string.isRequired,

}; render = (): React$Element<*> => {

const {

backgroundColor = 'black',

fontColor = 'white',

onPress,

size = 'medium',

style,

text,

} = this.props;

const computedStyles = styles(backgroundColor, fontColor, size); return (

<TouchableOpacity onPress={onPress}>

<View style={[computedStyles.container, style]}>

<Text style={computedStyles.text}>

{text.toUpperCase()}

</Text>

</View>

</TouchableOpacity>

);

};

} const styles = (

backgroundColor: string,

fontColor: string,

size: string,

): StyleSheet.styles =>

StyleSheet.create({

container: {

backgroundColor: backgroundColor,

borderRadius: 3,

padding: getButtonPadding(size),

},

text: {

backgroundColor: 'transparent',

color: fontColor,

fontFamily: Platform.OS === 'web' ? 'sans-serif' : undefined,

fontSize: getButtonFontSize(size),

fontWeight: 'bold',

textAlign: 'center',

},

});

Now that we have a button component, let’s add a story so we can see it rendered. In button/__stories__/index.js add the following:

import React from 'react';

import { storiesOf } from '@storybook/react';

import { View, Text, StyleSheet } from 'react-native'; import Button from '../'; storiesOf('Universal Components', module).add('Button', () => {

return (

<View style={styles.container}>

<Text style={styles.title}>Button</Text>

<View style={styles.example}>

<Text style={styles.exampleTitle}>Example</Text>

<View style={styles.exampleWrapper}>

<Button

text="Press Me!"

onPress={() => alert('Button Pressed!')}

/>

</View>

</View>

</View>

);

}); const styles = StyleSheet.create({

container: {

padding: 32,

},

example: {

borderColor: '#dddddd',

borderWidth: 1,

display: 'inline-flex',

flex: 0,

padding: 16,

},

exampleTitle: {

fontFamily: 'sans-serif',

fontSize: 18,

fontWeight: 'bold',

marginBottom: 12,

},

exampleWrapper: {

width: 300,

},

title: {

fontFamily: 'sans-serif',

fontSize: 24,

fontWeight: 'bold',

marginBottom: 24,

},

});

With a story set up, we can now run storybook and see the component in action. From the universal-components directory run yarn storybook . That’s it. You now have a development environment up and running. 🐎

Universal Components Storybook

Setting Up Testing

Building components in Storybook definitely reduces the chance of error but if you want to test functionality programmatically you will need to set up testing. For testing universal components you can use Jest and Enzyme which are already largely used in the React community.

First thing we have to do is add our testing dependencies:

// from universal-components directory yarn add -D enzyme enzyme-to-json jest

Now that we have our testing dependencies we need to set up some tests. In the button directory in components add a new directory called __tests__ with an index.js file.

components/

button/

__stories__/

index.js

__tests__/

index.js

index.js

Add the following to __tests__/index.js :

import React from 'react';

import { mount } from 'enzyme';

import { mountToJson } from 'enzyme-to-json'; import Button from '../'; describe('<Button />', () => {

it('<Button text="Test" />', () => {

const wrapper = mount(

<Button text="Test" onPress={() => {}} />

);

expect(mountToJson(wrapper)).toMatchSnapshot();

});



it('onPress()', () => {

const spy = jest.fn();

const wrapper = mount(<Button text="Test" onPress={spy} />); wrapper

.find('TouchableOpacity')

.first()

.props()

.onPress();



expect(spy).toBeCalled();

});

});

Now we need to add a script entry in package.json so we can run our tests easily.

// in universal-components/package.json "scripts": {

"test": "jest",

...

},

With a few tests in place and our package.json updated, we can now run yarn test to ensure everything is working.

Jest Tests

Deploying

The last step is deploying both Storybook and our universal components library. Let’s start with storybook.

We already added a script for building Storybook for production ( "build": "build-storybook" ), but we still need a way to deploy our Storybook so that users of our universal components library know what is available to them. To accomplish this we’ll use surge.sh .

First we need to add surge as a new devDependency :

// from universal-components directory yarn add -D surge

Next we need to add a script :

"scripts": {

"deploy": "yarn build && surge ./storybook-static",

...

}

Don’t forget to add packages/universal-components/storybook-static to your .gitignore !

And last but not least we need to publish our universal components package. However, before we can we should configure an .npmignore in our universal-components packages so we don’t deploy any unnecessary files.

node_modules

.storybook

.babelrc

Earlier I mentioned that we’re using Lerna to make publishing packages easier. To publish a new version of the package run lerna publish from the root directory of the project (not the universal-components directory).

That’s it! You now have a full universal component workflow up and running! Give yourself a pat on the back, or a virtual high-five! ✋

If you have any questions about this universal components implementation feel free to comment below or reach out to me on Twitter, my DMs are always open!