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.
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](https://github.com/necolas/react-native-web)
(RNW) and [react-primitives](https://github.com/lelandrichardson/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.
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.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.jsonpackage.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/.gitignorelerna.jsonpackage.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.jsonlerna.jsonpackage.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:
.web.js
extension support.default
async/await
and rest/spread
operators (supported by React Native)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 adevDependency
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.babelrcpackage.jsonyarn.locklerna.jsonpackage.json
Alright! Now we’re ready to develop our first universal component! 🎉
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/.babelrcpackage.jsonyarn.locklerna.jsonpackage.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 livetrue, // 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 syntaxconst MinifyPlugin = require('babel-minify-webpack-plugin');
// get default webpack config for @storybook/reactconst 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.jsindex.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}><Buttontext="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
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.jsindex.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
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](http://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 theuniversal-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!