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](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
, andreact-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 aTouchable
api in React Native but itâs not a component likeTouchableOpacity
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.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:
- babel-plugin-module-resolver: used for aliasing and addingÂ
.web.js
extension support - babel-plugin-transform-class-properties: 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
- babel-preset-flow: used to add Flow support
- babel-preset-react: used to add React support
- babel-preset-stage-2: used to add things like
async/await
andrest/spread
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 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! đ
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/.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
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.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
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](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!