Building a Component Library with Storybook

Configure Storybook

App/index.js

export default from '../storybook';

Build a Button Component

App/components/Button.js

import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';

import colors from '../config/colors';

const styles = StyleSheet.create({
  container: {
    backgroundColor: colors.primary,
    paddingVertical: 14,
    borderRadius: 6,
    borderWidth: 1,
    borderColor: colors.primary,
    marginVertical: 7,
  },
  containerOutline: {
    backgroundColor: 'transparent',
    borderColor: colors.border,
  },

  text: {
    color: colors.white,
    alignSelf: 'center',
    fontSize: 18,
    fontWeight: '500',
  },
  textOutline: {
    color: colors.primary,
  },
});

export const Button = ({
  onPress = () => {},
  children = '',
  outline = false,
}) => {
  const containerStyles = [styles.container];
  const textStyles = [styles.text];

  if (outline) {
    containerStyles.push(styles.containerOutline);
    textStyles.push(styles.textOutline);
  }

  return (
    <TouchableOpacity onPress={onPress} style={containerStyles}>
      <Text style={textStyles}>{children}</Text>
    </TouchableOpacity>
  );
};

App/config/colors.js

export default {
  primary: '#202c41',
  border: '#c6c6c6',
  white: '#fff',
  gray: '#9ca5ab',
  error: '#b55464',
  success: '#50c356',
};

App/stories/Button.stories.js

import React from 'react';
import { storiesOf } from '@storybook/react-native';
import { action } from '@storybook/addon-actions';

import { Button } from '../components/Button';
import { BufferView } from './decorators';

storiesOf('Button', module)
  .addDecorator(BufferView)
  .add('default', () => (
    <Button onPress={action('tapped-default')}>Press Me</Button>
  ))
  .add('outline', () => (
    <Button onPress={action('tapped-outline')} outline>
      Press Me
    </Button>
  ));

App/stories/decorators.js

import React from 'react';
import { View } from 'react-native';

export const BufferView = storyFn => (
  <View style={{ flex: 1, marginVertical: 40, marginHorizontal: 20 }}>
    {storyFn()}
  </View>
);

storybook/index.js

import { AppRegistry } from 'react-native';
import { getStorybookUI, configure } from '@storybook/react-native';

import './rn-addons';

// import stories
configure(() => {
  // require('./stories');
  require('../App/stories/Button.stories');
}, module);

// Refer to https://github.com/storybooks/storybook/tree/master/app/react-native#start-command-parameters
// To find allowed options for getStorybookUI
const StorybookUIRoot = getStorybookUI({});

// If you are using React Native vanilla and after installation you don't see your app name here, write it manually.
// If you use Expo you can safely remove this line.
AppRegistry.registerComponent('%APP_NAME%', () => StorybookUIRoot);

export default StorybookUIRoot;

Tip: Automatically Load Storybook Stories

package.json

{
  "name": "ComponentLibrary",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start",
    "test": "jest",
    "storybook": "storybook start",
    "prestorybook": "rnstl"
  },
  "dependencies": {
    "react": "16.6.3",
    "react-native": "0.57.8"
  },
  "devDependencies": {
    "@storybook/addon-actions": "^4.1.6",
    "@storybook/addon-links": "^4.1.6",
    "@storybook/addons": "^4.1.6",
    "@storybook/react-native": "^4.1.6",
    "babel-core": "^6.26.3",
    "babel-jest": "23.6.0",
    "babel-runtime": "^6.26.0",
    "jest": "23.6.0",
    "metro-react-native-babel-preset": "0.51.1",
    "prop-types": "^15.6.2",
    "react-dom": "16.6.3",
    "react-native-storybook-loader": "^1.8.0",
    "react-test-renderer": "16.6.3"
  },
  "jest": {
    "preset": "react-native"
  },
  "config": {
    "react-native-storybook-loader": {
      "searchDir": ["./App"],
      "pattern": "**/*.stories.js"
    }
  }
}

storybook/index.js

import { AppRegistry } from 'react-native';
import { getStorybookUI, configure } from '@storybook/react-native';
import { loadStories } from './storyLoader';

import './rn-addons';

// import stories
configure(() => {
  loadStories();
}, module);

// Refer to https://github.com/storybooks/storybook/tree/master/app/react-native#start-command-parameters
// To find allowed options for getStorybookUI
const StorybookUIRoot = getStorybookUI({});

// If you are using React Native vanilla and after installation you don't see your app name here, write it manually.
// If you use Expo you can safely remove this line.
AppRegistry.registerComponent('%APP_NAME%', () => StorybookUIRoot);

export default StorybookUIRoot;

Form Wrapper Component

App/components/Form/Form.js

import React from 'react';
import { View, StyleSheet, Text } from 'react-native';

import colors from '../../config/colors';

const styles = StyleSheet.create({
  container: {
    marginHorizontal: 20,
    flex: 1,
  },
  headerText: {
    color: colors.primary,
    fontWeight: '600',
    fontSize: 32,
    marginBottom: 12,
  },
  topRow: {
    marginBottom: 28,
  },
  subHeaderText: {
    color: colors.gray,
    fontSize: 20,
    marginBottom: 12,
  },
});

export default ({ children, header, subheader }) => (
  <View style={styles.container}>
    {(header || subheader) && (
      <View style={styles.topRow}>
        {header && <Text style={styles.headerText}>{header}</Text>}
        {subheader && <Text style={styles.subHeaderText}>{subheader}</Text>}
      </View>
    )}
    {children}
  </View>
);

App/components/Form/index.js

import Form from './Form';

export { Form };

App/stories/Form.stories.js

import React from 'react';
import { View, Text } from 'react-native';
import { storiesOf } from '@storybook/react-native';
import { action } from '@storybook/addon-actions';

import { BufferView } from './decorators';
import { Form } from '../components/Form';

storiesOf('Form', module)
  .addDecorator(BufferView)
  .add('default', () => (
    <Form>
      <View style={{ flex: 1, backgroundColor: '#e6e6e6' }} />
    </Form>
  ))
  .add('with header', () => (
    <Form header="Hello.">
      <View style={{ flex: 1, backgroundColor: '#e6e6e6' }} />
    </Form>
  ))
  .add('with header and subheader', () => (
    <Form
      header="Hello."
      subheader="Welcome back. Kindly enter your login details."
    >
      <View style={{ flex: 1, backgroundColor: '#e6e6e6' }} />
    </Form>
  ));

Field Wrapper Component

App/components/Form/FieldWrapper.js

import React from 'react';
import { View, Text, StyleSheet } from 'react-native';

import colors from '../../config/colors';

const styles = StyleSheet.create({
  container: {
    marginBottom: 20,
  },
  labelText: {
    color: colors.gray,
    fontSize: 18,
    marginBottom: 10,
  },
  messageText: {
    color: colors.gray,
    fontSize: 13,
    marginTop: 5,
  },
  messageSuccess: {
    color: colors.success,
  },
  messageError: {
    color: colors.error,
  },
});

export default ({ children, label = '', message = '', messageType }) => {
  const messageStyles = [styles.messageText];

  if (messageType === 'success') {
    messageStyles.push(styles.messageSuccess);
  } else if (messageType === 'error') {
    messageStyles.push(styles.messageError);
  }

  return (
    <View style={styles.container}>
      <Text style={styles.labelText}>{label}</Text>
      {children}
      <Text style={messageStyles}>{message}</Text>
    </View>
  );
};

App/components/Form/index.js

import Form from './Form';
import FieldWrapper from './FieldWrapper';

export { Form, FieldWrapper };

App/stories/Form.stories.js

import React from 'react';
import { View, Text } from 'react-native';
import { storiesOf } from '@storybook/react-native';
import { action } from '@storybook/addon-actions';

import { BufferView } from './decorators';
import { Form } from '../components/Form';
import { Form, FieldWrapper } from '../components/Form';

storiesOf('Form/FieldWrapper', module)
  .addDecorator(BufferView)
  .add('default', () => (
    <FieldWrapper label="Email">
      <Text>Hello, wrapper.</Text>
    </FieldWrapper>
  ))
  .add('error message', () => (
    <FieldWrapper
      label="Email"
      message="Please enter a valid email!"
      messageType="error"
    >
      <Text>Hello, wrapper.</Text>
    </FieldWrapper>
  ))
  .add('success message', () => (
    <FieldWrapper label="Email" message="Looks legit!" messageType="success">
      <Text>Hello, wrapper.</Text>
    </FieldWrapper>
  ))
  .add('multiple fields', () => (
    <React.Fragment>
      <FieldWrapper label="Email">
        <Text>Hello, wrapper.</Text>
      </FieldWrapper>
      <FieldWrapper
        label="Email"
        message="Please enter a valid email!"
        messageType="error"
      >
        <Text>Hello, wrapper.</Text>
      </FieldWrapper>
      <FieldWrapper label="Email" message="Looks legit!" messageType="success">
        <Text>Hello, wrapper.</Text>
      </FieldWrapper>
    </React.Fragment>
  ));

// ...

TextInput Component

App/components/Form/TextInput.js

import React from 'react';
import { TextInput, StyleSheet, View } from 'react-native';

import colors from '../../config/colors';
import FieldWrapper from './FieldWrapper';

const styles = StyleSheet.create({
  textInput: {
    fontSize: 14,
    fontWeight: '500',
    paddingBottom: 10,
  },
  border: {
    height: 1,
    backgroundColor: colors.border,
  },
});

export default ({ label, message, messageType, ...rest }) => (
  <FieldWrapper label={label} message={message} messageType={messageType}>
    <TextInput style={styles.textInput} {...rest} />
    <View style={styles.border} />
  </FieldWrapper>
);

App/components/Form/index.js

import Form from './Form';
import FieldWrapper from './FieldWrapper';
import TextInput from './TextInput';

export { Form, FieldWrapper, TextInput };

App/stories/Form.stories.js

import React from 'react';
import { View, Text } from 'react-native';
import { storiesOf } from '@storybook/react-native';
import { action } from '@storybook/addon-actions';

import { BufferView } from './decorators';
import { Form, FieldWrapper } from '../components/Form';
import { Form, FieldWrapper, TextInput } from '../components/Form';

const defaultTextInputProps = {
  label: 'Demo',
  onChangeText: action('onChangeText'),
};
storiesOf('Form/TextInput', module)
  .addDecorator(BufferView)
  .add('default', () => <TextInput {...defaultTextInputProps} />)
  .add('with placeholder', () => (
    <TextInput {...defaultTextInputProps} placeholder="demo placeholder" />
  ))
  .add('with value', () => (
    <TextInput {...defaultTextInputProps} value="hello value" />
  ))
  .add('with error message', () => (
    <TextInput
      {...defaultTextInputProps}
      message="This is an error"
      messageType="error"
    />
  ))
  .add('email', () => (
    <TextInput
      {...defaultTextInputProps}
      label="Email"
      value="spencer@reactnativeschool.com"
      keyboardType="email-address"
    />
  ))
  .add('password', () => (
    <TextInput
      {...defaultTextInputProps}
      label="Password"
      value="password"
      secureTextEntry
    />
  ));

// ...

Switch Component

App/components/Form/Switch.js

import React from 'react';
import { Switch } from 'react-native';

import FieldWrapper from './FieldWrapper';

export default ({ label, message, messageType, ...rest }) => (
  <FieldWrapper label={label} message={message} messageType={messageType}>
    <Switch {...rest} />
  </FieldWrapper>
);

App/components/Form/index.js

import Form from './Form';
import FieldWrapper from './FieldWrapper';
import TextInput from './TextInput';
import Switch from './Switch';

export { Form, FieldWrapper, TextInput, Switch };

App/stories/Form.stories.js

import React from 'react';
import { View, Text } from 'react-native';
import { storiesOf } from '@storybook/react-native';
import { action } from '@storybook/addon-actions';

import { BufferView } from './decorators';
import { Form, FieldWrapper, TextInput, Switch } from '../components/Form';

storiesOf('Form/Switch', module)
  .addDecorator(BufferView)
  .add('default', () => <Switch label="Agree to Terms" />)
  .add('with error', () => (
    <Switch
      label="Agree to Terms"
      message="You must agree to the terms"
      messageType="error"
    />
  ));

// ...

Pulling it Together: Signup Form

App/index.js

import React from 'react';
import { SafeAreaView } from 'react-native';
import { Form, TextInput, Switch } from './components/Form';
import { Button } from './components/Button';

export default () => (
  <SafeAreaView style={{ flex: 1 }}>
    <Form header="Hello." subheader="Please create a new account">
      <TextInput label="Email" keyboardType="email-address" />
      <TextInput label="Password" secureTextEntry />
      <TextInput label="Confirm Password" secureTextEntry />
      <Switch label="Agree to Terms" />
      <Button>Sign Up</Button>
      <Button outline>Sign In</Button>
    </Form>
  </SafeAreaView>
);

// export default from "../storybook";

Bonus: Automatic Snapshot Tests

package.json

{
  "name": "ComponentLibrary",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start",
    "test": "jest",
    "storybook": "storybook start",
    "prestorybook": "rnstl"
  },
  "dependencies": {
    /* ... */
  },
  "devDependencies": {
    /* ... */
  },
  "jest": {
    "preset": "react-native",
    "transform": {
      "^.+\\.js$": "<rootDir>/node_modules/react-native/jest/preprocessor.js"
    }
  },
  "config": {
    "react-native-storybook-loader": {
      "searchDir": ["./App"],
      "pattern": "**/*.stories.js"
    }
  }
}

storybook/storyshots.test.js

import initStoryshots from '@storybook/addon-storyshots';

initStoryshots();

Exercise: Build Your Own Component Library

You’ve watched me do it, now it’s your turn!

My challenge exercise to you is to build a library of components to build a flexible list, similar to the one below which comes from React Native Elements.

Follow the process we took throughout this guide:

  1. Initialize Storybook in an app
  2. Break the UI into different components
  3. Create stories for each component
  4. Create stories for each version of the component
  5. Implement the component

Share your version in the Slack community!

Report a problem