Recently I created a small React Native component library using Restyle, Shopify's new native styling library. I thought I'd document the process of creating a React Native specific component library, and the intricacies behind the going from component code to a distribution build with automated versioning using a CI/CD.

We'll create a React Native component library with a build system, linting, types with Typescript, unit testing, integration tests and documentation with Storybook, and a release system using semantic-release. And we'll even setup a CI/CD (CircleCI in this case) to run our linting, testing, and builds.

This won't cover the design process, or any differences between native and web components. But this will cover things like build process and documentation, as well as comparing the native process to web. I'd check out the React Native documentation if you're not familiar with the library, it's a fantastic resource for getting started from a few different perspectives.

If you're interested in the source code, check it out here and give it a test ride. Or keep reading to see how it's built from the ground up 👇🏼

Creating your package

Normally you'd use npm init to get started, or if you follow the React Native docs, you'd use the bob CLI to spin up a project with a build system. But I wanted Storybook. And to have Storybook, you need Expo.

And that is a whole article itself to show you how to setup, so I setup a template expo-storybook. This will be our starting point. This template comes with a bit setup out of the box, so let's break it down:

  • Storybook
  • Typescript
  • Expo
  • Testing using Jest and react-test-renderer
  • Linting using ESLint

Storybook

This is basically a standard React Storybook setup, but it get's weird quick. The build system is run through the Expo Webpack configuration, which helps do things like take react-native references and make them react-native-web. If you run yarn storybook, you'll use the Storybook CLI to create a local web instance.

Then there's native Storybook. The "Storybook app" itself is run through Expo, meaning the root App.tsx file renders Storybook (not the same yarn storybook, this is running it natively in Expo). This allows you to test your components natively on your device using the Expo CLI and the Storybook mobile UI.

Currently the Storybook config (.storybook/config.js) grabs stories from /stories/ in the root of the repo, but you can set it up to grab from the component folder instead:

configure(require.context("../components", true, /\.stories\.[tj]sx$/), module);

Typescript

This one is the most standard setup. It's Typescript that's configured lightly by Expo, which you can read about in their docs. I had one issue with the default config, which I'll discuss below.

Expo

Expo is a set of utilities for working more easily with React Native. I used the Expo CLI to create a new project and used the managed Typescript template. This set up linting and testing, as well as Typescript support.

Testing

Jest and react-test-renderer are setup by Expo. Expo even provides an example test, which I believe I left in the repo for reference. Running yarn test runs any .test.[tj]sx files through Jest, which ideally is using react-test-renderer to render the components in isolation.

Linting / Formatting

ESLint is setup using the React Native community ESLint configuration. There's nothing too different about setting up ESLint with RN if you do it manually. Running yarn lint runs the ESLint check, and if you use an IDE like VSCode, you can benefit from built-in error checking.

Prettier is also setup to make sure files are formatted similarly. Running yarn format will go through all source files in repo and write over them.

Now that all this is setup, let's add a build system!

Build system

The React Native docs recommend using bob, a build system built for React Native modules (like Bob the Builder — yes we have a CLI!). Normally you'd use the bob CLI to bootstrap your React Native package, but since we have a project setup using Expo, we have to do it manually.

Run the following in the root of the package:

yarn add --dev @react-native-community/bob

Add an index.ts file that export all of your components (so bob can pick it up during the next CLI process). If you don't have a component, just create a quick sample one using <Text> component and export it from the index.ts.

Then run the initialization process:

yarn bob init

This will walk you through some questions, like selecting a build output. I recommend using CommonJS, ESModules, and Typescript. Afterwards, the CLI will add the necessary configurations to the package.json

I tried running yarn prepare to run the build but it failed due to a couple errors. First I had to remove the noEmit from the Typescript config, since Expo set's it to true by default to allow for Metro bundler to handle things — but since we're using bob for production builds, which needs to use Typescripts tsc to compile code, we remove it. Also the App.test.tsx used by Expo getting picked up and throwing errors about missing types. I added it to the exclude property of the tsconfig.json to ensure they didn't get picked up:

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "jsx": "react-native",
    "lib": ["dom", "esnext"],
    "moduleResolution": "node",
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "exclude": [
    "node_modules",
    "dist",
    "lib",
    "**/*.spec.ts",
    "**/*.stories.[tj]sx",
    "**/*.test.[tj]sx",
    "App.test.tsx",
    "App.tsx"
  ]
}

After this, running yarn prepare works:

Ryos-MacBook-Pro:restyle-ui ryo$ yarn prepare
yarn run v1.22.4
warning package.json: No license field
$ bob build
ℹ Building target commonjs
ℹ Cleaning up previous build at dist/commonjs
ℹ Compiling 4 files in components with babel
✓ Wrote files to dist/commonjs
ℹ Building target module
ℹ Cleaning up previous build at dist/module
ℹ Compiling 4 files in components with babel
✓ Wrote files to dist/module
ℹ Building target typescript
ℹ Cleaning up previous build at dist/typescript
ℹ Generating type definitions with tsc
✓ Wrote definition files to dist/typescript
✨  Done in 4.92s.

If you look at the Typescript folder in your preferred build directory, you can see all the types necessary for components and even the theme.

Semantic Release

  1. Add commitizen as a dev dependency to your project (or monorepo):

    npm i -D commitizen
    yarn add --dev commitizen -W
    

    The -W flag is for Yarn Workspaces to install it on the root workspace.

  2. Then run the setup to use the conventional-changelog:

    npx commitizen init cz-conventional-changelog -D -E
    
  3. Add a script to your package.json to run the conventional commit CLI when you have staged files to commit:

    "scripts": {
      "commit": "git-cz"
    },
    

You should be good to go! Stage some files in Git (git add .) and run yarn commit to start the CLI. The CLI will walk you through the commit process.

Enforcing commits with hooks

  1. Install husky, a tool that simplifies the process of creating git hooks:

    npm i -D husky
    yarn add --dev husky
    
  2. Install a linter for the commit messages:

    npm i -D @commitlint/{config-conventional,cli}
    yarn add --dev @commitlint/{config-conventional,cli}
    
  3. Create a configuration file for the commit linter in the project root as commitlint.config.js:

    module.exports = { extends: ["@commitlint/config-conventional"] };
    

    Instead of creating a new file, you can add this to your package.json:

    "commitlint": { "extends": ["@commitlint/config-conventional"] }
    
  4. Add the husky hook to your package.json:

    "husky": {
      "hooks": {
        "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
      }
    }
    

    Check the manual method to see a CI/CD override using cross-env. Since the CI/CD needs to version the software, it won't follow the commit conventions, so you need to configure the hook to deal with that.

Semantic Release

If you aren't using a utility like Lerna for managing your project, you'll need to setup a release process that increases the version of your package.

  1. Install semantic-release:

    npm i -D semantic-release
    yarn add --dev semantic-release
    
  2. Add a script to your package.json to run it:

    "scripts": {
      "semantic-release": "semantic-release"
    },
    
  3. Add your Github (GITHUB_TOKEN) and NPM tokens (NPM_TOKEN) to your CI service of choice.

    • Here's a sample CircleCI config .circleci/config.yml:

      version: 2
      jobs:
        test_node_10:
          docker:
            - image: circleci/node:10
          steps:
            - checkout
            - run: yarn install --frozen-lockfile
            - run: yarn run test:unit -u
      
        release:
          docker:
            - image: circleci/node:10
          steps:
            - checkout
            - run: yarn install --frozen-lockfile
            # Run optional required steps before releasing
            # - run: npm run build-script
            - run: npx semantic-release
      
      workflows:
        version: 2
        test_and_release:
          # Run the test jobs first, then the release only when all the test jobs are successful
          jobs:
            - test_node_10
            - release:
                filters:
                  branches:
                    only:
                      - master
                      - beta
                requires:
                  - test_node_10
      
    • Here's a version for Github Actions:

      name: CI
      on: [push]
      jobs:
        build:
          runs-on: ubuntu-latest
      
          steps:
            - name: Begin CI...
              uses: actions/checkout@v2
      
            - name: Use Node 12
              uses: actions/setup-node@v1
              with:
                node-version: 12.x
      
            - name: Use cached node_modules
              uses: actions/cache@v1
              with:
                path: node_modules
                key: nodeModules-${{ hashFiles('**/yarn.lock') }}
                restore-keys: |
                  nodeModules-
      
            - name: Install dependencies
              run: yarn install --frozen-lockfile
              env:
                CI: true
      
            - name: Lint
              run: yarn lint
              env:
                CI: true
      
            - name: Test
              run: yarn test --ci --coverage --maxWorkers=2
              env:
                CI: true
      
            - name: Build
              run: yarn build
              env:
                CI: true
      
            - name: Semantic Release
              run: yarn semantic-release
              env:
                CI: true
      

    Everything is ready now! If CI sees a commit message that should trigger a release (like those starting with feat or fix), all will happen automatically.

Changelog and Release

This generates creates a new commit in your git with a [CHANGELOG.md](http://changelog.md) file and any other files you specify (like a package.json that bumps the new version, of dist folder with JS and CSS production files).

  1. Install the packages:

    npm i -D @semantic-release/changelog @semantic-release/git
    
  2. Add this to your package.json:

    "release": {
    	"prepare": [
    	   "@semantic-release/changelog",
    	   "@semantic-release/npm",
    	   {
    	    "path": "@semantic-release/git",
    	     "assets": [
    				// Add any distribution files here
    				"dist/**/*.{js,ts}",
    	      "package.json",
    	      "package-lock.json",
    	      "CHANGELOG.md"
    	    ],
    	    "message": "chore(release): ${nextRelease.version} [skip ci]nn${nextRelease.notes}"
    	  }
    	]
    }
    

So what did we just do?

First we setup a "commit CLI" to help use write "conventional commits" that are used to automated version control. Stage some changes to Git (git add .) and then use yarn commit to run the CLI. It'll walk you through crafting the correct commit, and then actually commit your code.

Then we setup husky, a library used to more easily use git hooks. This allowed us to setup "commit linting", which checks every commit and makes sure it matches the "conventional commit" standard.

Third we setup semantic-release, which is the actual library we'll use to automate version control. Running yarn semantic-release will check all commits since the last version, and use the commits and their structure to increment the version as necessary (like a minor version push for a bug, or major for breaking change).

Finally we setup a couple plugins for semantic-release that make life easier. The changelog plugin generates a [CHANGELOG.md](http://changelog.md) file that contains relevant changes you made in commits. The git plugin creates a new commit with your distribution files when a new version is created (labeled with your version number). And the NPM version uses your NPM auth token from your CI/CD to publish for you.

How does it all come together?

  1. Create a component (or changes, like a bug fix).
  2. Stage your code using Git (git add)
  3. Commit your code using the CLI (yarn commit) or if you're confident, use git commit and write a conventional commit by hand (the commit linter will verify it for you).
  4. When you want to push a new version, run yarn semantic-release, or for better practice — use Github PR's, merge them into master, and trigger the CI/CD (which handles the entire release process for you).

Stepping up your branches

You can merge everything to master in the beginning, but what happens when you want to test new features and create a build for it? This is where a next and beta branches come in.

The next branch is used to push all new code into it. This should be where all bug fixes, upgrades, etc happen. Then when you feel confident to release, you push this to beta, which can trigger a beta build of the software for testing.

Then after the software is properly tested (and bug fixed), you can release this to the public by merging the beta and master branches. You should have no conflicts, since the beta changes are all upstream from the master branch (meaning it's all fresh code coming in — you shouldn't have any other commits to master conflicting).

Contributor "beta" workflow

  1. Create a branch to work (git checkout -b feat/new-component)
  2. Submit branch to repo. This should trigger testing.
  3. If tests pass, it can be merged into next branch.
  4. When release time is nearly ready (product is tested, enough features to justify) you merge next with beta. You can do this through Github pull requests.
  5. This will create a beta build you can provide to testers using CircleCI.
  6. Merge any bug fixes to beta, then merge with master when ready for major release.
  7. This creates a release for the master branch using CircleCI.

Start making components!

I hope this simplifies the process for starting a new React Native component library for you! It helps you get immediate feedback using Storybook, sanity checks using linting, and all the bells and whistles when it's time to push code to the public.

If you want to give it a shot without the setup, or have issues along the way, you can grab the template from Github here and compare to your work.

Let me know what you think, or if you have any suggestions or issues, in the comments or on my Twitter.

References

Tools

Templates

Semantic Release

Table of Contents