loading...
Cover image for React Project - Idea to Production - Part Two - Setting up a Component Library

React Project - Idea to Production - Part Two - Setting up a Component Library

debojitroy profile image DEBOJIT ROY ・12 min read

This was originally posted here

This is the second post in the series. You can find the first post here

Where are we

Ok so till now we have

  • Brainstormed on our brilliant idea to build a Movie App.
  • We have decided what features are needed as part of the MVP.
  • Our design team has given us the wireframes.
  • We have setup our project as a Monorepo.
  • We have setup linting rules, code formatter and commit hooks.

What are we going to do now

Ok so the next step is to break down our wireframe into components. We will build a component library which can be used across various projects. Finally we will setup storybook to showcase our component library.

TL;DR

This is a 5 part post

Source Code is available here

Component Library Demo is available here

Movie App Demo is available here

Setting the Component Library

Now let's move ahead by setting up our component library.

Move to the packages folder

cd packages

Create a new folder for our components

mkdir components
cd components

Initialize the yarn project

yarn init

Naming is important here as we will be referring our projects in our workspace using the name. I prefer organization scoped name to avoid naming conflicts. So for our example I will be using @awesome-movie-app as our organization name. Feel free to replace with your organization scope.

Next thing to keep in mind is how you want to publish your packages to npm. If you would like to publish packages to npm, then make sure the version is semantic and let lerna handle the publishing to packages.

If you have a restricted / private NPM organization, make sure to add publishConfig with restricted access in your package.json to avoid accidental publishing of the packages to public npm.

"publishConfig": {
    "access": "restricted"
}

As for the purpose of this post, we will not be publishing our packages to npm, so we will skip defining the publishConfig.

So our package.json looks like

{
  "name": "@awesome-movie-app/components",
  "version": "1.0.0",
  "description": "Component Library for Awesome Movie App",
  "main": "index.js",
  "repository": "git@github.com:debojitroy/movie-app.git",
  "author": "Debojit Roy <debojity2k@gmail.com>",
  "license": "MIT",
  "private": true
}

Defining the requirements

Our project is now setup, lets define our requirements before we move further.

  • Our components will be React components
  • We will use TypeScript to build our components
  • We want to showcase our components using Storybook
  • We will use Bootstrap for base styles
  • We will adopt CSS-in-JS and use StyledComponents
  • We will transpile our code using Babel

Why no Webpack

In a ideal world we will be publishing our packages to npm. Before publishing our packages to npm we would want to transpile and package them nicely. For this my ideal choice will be webpack.

But one very important feature for libraries is the package should support Tree Shaking. Tree Shaking is a fancy word for trimming excess fat i.e. eliminating code that is not used in the importing library. Due to this known webpack issue, sadly it makes it impossible right now.

To work around the problem we can use Rollup, but as we are not interested right now in publishing our package to npm, we will use babel to transpile our components. I will cover how to use Rollup and tree shake your library in another post.

Preparing the project

Ok that was way too much theory, now let's move on to setup our project.

Last bit of theory before we move ahead. As we are using lerna as our high-level dependency manager, we will use lerna to manage dependencies. Which means to add a new dependency we will use this format

lerna add <dependency-name> --scope=<sub-project-name> <--dev>

dependency-name: Name of the npm package we want to install
sub-project-name: This is optional. If you omit this then the dependency will be installed across all the projects. If you want the dependency to be installed only for a specifc project, then pass in the name of the project from individual package.json
--dev: Same as yarn options. If you want to install only dev dependencies, pass in this flag.

Adding Project Dependencies

Usually I will go ahead and add most of the dependencies in one command. But for this post I will go verbose explaining each of the dependencies I am adding and the reasoning behind it.

Note: We will be adding everything from the root folder of the project i.e. the root folder of movie-app (one level above packages folder)

Adding React

lerna add react --scope=@awesome-movie-app/components --dev
lerna add react-dom --scope=@awesome-movie-app/components --dev

Why one dependency at a time

Sadly due to this limitation of lerna 😞

Why is React a dev dependency 🤔

This part is important. As this library will be consumed in other project, we don't want to dictate our version of React, rather we want the consuming project to inject the dependency. So we are going to add common libraries as dev dependencies and mark them as peer dependencies. This is true for any common libraries you may want to build.

We will be adding React in our peer dependencies of @awesome-movie-app/components

"peerDependencies": {
    "react": "^16.13.1",
    "react-dom": "^16.13.1"
  }

Adding TypeScript

lerna add typescript --scope=@awesome-movie-app/components --dev

Adding types for React

lerna add @types/node --scope=@awesome-movie-app/components
lerna add @types/react --scope=@awesome-movie-app/components
lerna add @types/react-dom --scope=@awesome-movie-app/components

Adding tsconfig for TypeScript

{
  "compilerOptions": {
    "outDir": "lib",
    "module": "commonjs",
    "target": "es5",
    "lib": ["es5", "es6", "es7", "es2017", "dom"],
    "sourceMap": true,
    "allowJs": false,
    "jsx": "react",
    "moduleResolution": "node",
    "rootDirs": ["src"],
    "baseUrl": "src",
    "forceConsistentCasingInFileNames": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "suppressImplicitAnyIndexErrors": true,
    "noUnusedLocals": true,
    "declaration": true,
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "build", "scripts"]
}

Adding Storybook

lerna add @storybook/react --scope=@awesome-movie-app/components --dev

Adding some cool add-ons

lerna add @storybook/addon-a11y --scope=@awesome-movie-app/components --dev
lerna add @storybook/addon-actions --scope=@awesome-movie-app/components --dev
lerna add @storybook/addon-docs --scope=@awesome-movie-app/components --dev
lerna add @storybook/addon-knobs --scope=@awesome-movie-app/components --dev
lerna add @storybook/addon-viewport --scope=@awesome-movie-app/components --dev
lerna add storybook-addon-styled-component-theme --scope=@awesome-movie-app/components --dev
lerna add @storybook/addon-jest --scope=@awesome-movie-app/components --dev

Adding Test Libraries

We will be using jest for unit testing

lerna add jest --scope=@awesome-movie-app/components --dev
lerna add ts-jest --scope=@awesome-movie-app/components --dev

We will be using enzyme for testing our React Components

lerna add enzyme --scope=@awesome-movie-app/components --dev
lerna add enzyme-adapter-react-16 --scope=@awesome-movie-app/components --dev
lerna add enzyme-to-json --scope=@awesome-movie-app/components --dev

Adding jest-styled-components for supercharing jest

lerna add jest-styled-components --scope=@awesome-movie-app/components --dev

Configure enzyme and jest-styled-components to work with jest. We will add setupTests.js

require("jest-styled-components")
const configure = require("enzyme").configure
const EnzymeAdapter = require("enzyme-adapter-react-16")

const noop = () => {}
Object.defineProperty(window, "scrollTo", { value: noop, writable: true })
configure({ adapter: new EnzymeAdapter() })

Configure jest.config.js

module.exports = {
  preset: "ts-jest",
  // Automatically clear mock calls and instances between every test
  clearMocks: true,

  // Indicates whether the coverage information should be collected while executing the test
  collectCoverage: true,

  // An array of glob patterns indicating a set of files for which coverage information should be collected
  collectCoverageFrom: [
    "src/**/*.{ts,tsx}",
    "!src/**/index.{ts,tsx}",
    "!src/**/styled.{ts,tsx}",
    "!src/**/*.stories.{ts,tsx}",
    "!node_modules/",
    "!.storybook",
    "!dist/",
    "!lib/",
  ],

  // The directory where Jest should output its coverage files
  coverageDirectory: "coverage",

  // An array of regexp pattern strings used to skip test files
  testPathIgnorePatterns: ["/node_modules/", "/lib/", "/dist/"],

  // A list of reporter names that Jest uses when writing coverage reports
  coverageReporters: ["text", "html", "json"],

  // An array of file extensions your modules use
  moduleFileExtensions: ["ts", "tsx", "js", "jsx"],

  // A list of paths to modules that run some code to configure or set up the testing framework before each test
  setupFilesAfterEnv: ["./setupTests.js"],

  // A list of paths to snapshot serializer modules Jest should use for snapshot testing
  snapshotSerializers: ["enzyme-to-json/serializer"],
}

Adding Styled Components and BootStrap

lerna add styled-components --scope=@awesome-movie-app/components --dev
lerna add react-bootstrap --scope=@awesome-movie-app/components --dev
lerna add bootstrap --scope=@awesome-movie-app/components --dev

lerna add @types/styled-components --scope=@awesome-movie-app/components

Adding Babel

As we will be using babel to transpile everything. Its important we configure Babel properly.

Adding Babel Dependencies

lerna add @babel/core --scope=@awesome-movie-app/components --dev
lerna add babel-loader --scope=@awesome-movie-app/components --dev
lerna add @babel/cli --scope=@awesome-movie-app/components --dev
lerna add @babel/preset-env --scope=@awesome-movie-app/components --dev
lerna add @babel/preset-react --scope=@awesome-movie-app/components --dev
lerna add @babel/preset-typescript --scope=@awesome-movie-app/components --dev
lerna add core-js --scope=@awesome-movie-app/components --dev

A bit on the babel components we added

  • @babel/core : Core babel functionality
  • babel-loader : Used by storybook webpack builder
  • @babel/cli : Will be used by us to transpile files from command line
  • @babel/preset-env : Environment setting for transpiling
  • @babel/preset-react : React setting for babel
  • @babel/preset-typescript : TypeScript settings for babel
  • core-js : Core JS for preset-env

Now let's add our .babelrc file

{
  "presets": [
    "@babel/preset-typescript",
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "entry",
        "corejs": "3",
        "modules": false
      }
    ],
    "@babel/preset-react"
  ]
}

Bringing it all together

Important Note

The steps below may differ based on which version of Storybook and Jest you are using. The below steps are written for Storybook v5.3+ and Jest v26.0+

Setting up our theme

First step will be to setup our theme. We can start with a blank theme and fill it up as we go.

cd packages/components
mkdir theme

Defining the Theme

export interface Theme {
  name: string
  color: {
    backgroundColor: string
    primary: string
    secondary: string
  }
}

Defining Light theme

import { Theme } from "./theme"

const lightTheme: Theme = {
  name: "LIGHT",
  color: {
    backgroundColor: "#fff",
    primary: "#007bff",
    secondary: "#6c757d",
  },
}

export default lightTheme

Defining Dark theme

import { Theme } from "./theme"

const darkTheme: Theme = {
  name: "DARK",
  color: {
    backgroundColor: "#000",
    primary: "#fff",
    secondary: "#6c757d",
  },
}

export default darkTheme

Setting up Storybook

To configure storybook, we need to setup the configuration folder first. We will use the default .storybook folder, but feel free to use folder name.

mkdir .storybook

Now inside .storybook folder we will create the configuration files needed for storybook

main.js

This is the main configuration file for storybook. We will configure the path for stories, register our addons and override webpack config to process typescript files.

// .storybook/main.js

module.exports = {
  stories: ["../src/**/*.stories.[tj]sx"],
  webpackFinal: async config => {
    config.module.rules.push({
      test: /\.(ts|tsx)$/,
      use: [
        {
          loader: require.resolve("ts-loader"),
        },
      ],
    })
    config.resolve.extensions.push(".ts", ".tsx")
    return config
  },
  addons: [
    "@storybook/addon-docs",
    "@storybook/addon-actions/register",
    "@storybook/addon-viewport/register",
    "@storybook/addon-a11y/register",
    "@storybook/addon-knobs/register",
    "storybook-addon-styled-component-theme/dist/register",
    "@storybook/addon-jest/register",
  ],
}

manager.js

Here we configure the Storybook manager. There are many options that can be overriden, for our project we want the add-ons panel to be at the bottom (default is right)

// .storybook/manager.js

import { addons } from "@storybook/addons"

addons.setConfig({
  panelPosition: "bottom",
})

preview.js

Finally we will configure the Story area. We intialize our add-ons and pass global configurations.

// .storybook/preview.js
import { addParameters, addDecorator } from "@storybook/react"
import { withKnobs } from "@storybook/addon-knobs"
import { withA11y } from "@storybook/addon-a11y"
import { withThemesProvider } from "storybook-addon-styled-component-theme"
import { withTests } from "@storybook/addon-jest"
import results from "../.jest-test-results.json"
import lightTheme from "../theme/light"
import darkTheme from "../theme/dark"

export const getAllThemes = () => {
  return [lightTheme, darkTheme]
}

addDecorator(withThemesProvider(getAllThemes()))

addDecorator(withA11y)
addDecorator(withKnobs)

addDecorator(
  withTests({
    results,
  })
)

addParameters({
  options: {
    brandTitle: "Awesome Movie App",
    brandUrl: "https://github.com/debojitroy/movie-app",
    showRoots: true,
  },
})

Creating React Components

Now we can create our very first react component.

Our first button

We will first create a src folder

mkdir src && cd src

Then we will create a folder for our component. Let's call it Sample

mkdir Sample && cd Sample

Now let's create a simple styled button and pass some props to it.

// styled.ts
import styled from "styled-components"

export const SampleButton = styled.button`
  background-color: ${props => props.theme.color.backgroundColor};
  color: ${props => props.theme.color.primary};
`
// Button.tsx
import React from "react"
import { SampleButton } from "./styled"

const Button: React.FC<{
  value: string
  onClickHandler: () => void
}> = ({ value, onClickHandler }) => (
  <SampleButton onClick={onClickHandler}>{value}</SampleButton>
)

export default Button

Awesome !!! We have our first component finally !!!

Adding unit tests

Now let's add some tests for our new button.

mkdir tests
// tests/Button.test.tsx

import React from "react"
import { mount } from "enzyme"
import { ThemeProvider } from "styled-components"
import lightTheme from "../../../theme/light"
import Button from "../Button"

const clickFn = jest.fn()
describe("Button", () => {
  it("should simulate click", () => {
    const component = mount(
      <ThemeProvider theme={lightTheme}>
        <Button onClickHandler={clickFn} value="Hello" />
      </ThemeProvider>
    )
    component.find(Button).simulate("click")
    expect(clickFn).toHaveBeenCalled()
  })
})

Adding stories

Now with the new button in place, lets add some stories

mkdir stories

We will use the new Component Story Format (CSF)

// stories/Button.stories.tsx

import React from "react"
import { action } from "@storybook/addon-actions"
import { text } from "@storybook/addon-knobs"
import Button from "../Button"

export default {
  title: "Sample / Button",
  component: Button,
}

export const withText = () => (
  <Button
    value={text("value", "Click Me")}
    onClickHandler={action("button-click")}
  />
)

withText.story = {
  parameters: {
    jest: ["Button.test.tsx"],
  },
}

Time to check if everything works

Transpiling our code

As we discussed in the beginning, we will be using babel to transpile our code and let the calling projects take care of minification and tree-shaking.

So going ahead with that, we will add some scripts and test they are working.

Typecheck and Compilation

We will first use TypeScript's compile to compile our code.

"js:build": "cross-env NODE_ENV=production tsc -p tsconfig.json"

If everything is fine, we should see an output like this

$ cross-env NODE_ENV=production tsc -p tsconfig.json
✨  Done in 5.75s.
Transpiling with Babel

Next step will be to transpile our code with babel

"build-js:prod": "rimraf ./lib && yarn js:build && cross-env NODE_ENV=production babel src --out-dir lib --copy-files --source-maps --extensions \".ts,.tsx,.js,.jsx,.mjs\""

If everything is fine, we should see an output like this

$ rimraf ./lib && yarn js:build && cross-env NODE_ENV=production babel src --out-dir lib --copy-files --source-maps --extensions ".ts,.tsx,.js,.jsx,.mjs"
$ cross-env NODE_ENV=production tsc -p tsconfig.json
Successfully compiled 4 files with Babel.
✨  Done in 7.02s.
Setting up watch mode for development

During development, we would like incremental compilation every time we make changes. So let's add a watch script.

"js:watch": "rimraf ./lib && cross-env NODE_ENV=development concurrently -k -n \"typescript,babel\" -c \"blue.bold,yellow.bold\"  \"tsc -p tsconfig.json --watch\" \"babel src --out-dir lib --source-maps --extensions \".ts,.tsx,.js,.jsx,.mjs\" --copy-files --watch --verbose\""

We should see output like this

Starting compilation in watch mode...
[typescript]
[babel] src/Sample/Button.tsx -> lib/Sample/Button.js
[babel] src/Sample/stories/Button.stories.tsx -> lib/Sample/stories/Button.stories.js
[babel] src/Sample/styled.ts -> lib/Sample/styled.js
[babel] src/Sample/tests/Button.test.tsx -> lib/Sample/tests/Button.test.js
[babel] Successfully compiled 4 files with Babel.
[typescript]
[typescript] - Found 0 errors. Watching for file changes.

Running Unit Tests

Once we are sure our compilation and transpiling works, lets make sure our tests work.

"test": "jest"

Running our tests should show an output similar to this

Jest Test Suite Output

We are getting there slowly 😊

Now we need to generate json output for storybook to consume and show next to our stories. Let's configure that as well.

"test:generate-output": "jest --json --outputFile=.jest-test-results.json || true"

Running Storybook

Finally we want to run storybook with our stories. Lets run storybook in dev mode.

"storybook": "start-storybook -p 8080"

If everything was configured properly, we should see the storybook in our Browser

Local Storybook in Browser

We will add couple of more commands for building storybook for deployment. We will be using these when we configure Continuous Deployment in our last post - Part Four: Hosting the Movie app and setting up CI/CD

"prebuild:storybook": "rimraf .jest-test-results.json && yarn test:generate-output",
"build:storybook": "build-storybook -c .storybook -o dist/"

After this we can start splitting our wireframes into components. I will not go into the details of that as there are much better posts out there which do a better job of explaining the process. You can find the code that we complete till now here

In the next part we will setup and build our movie app, continue to Part Three : Building the Movie App using component library

Posted on by:

debojitroy profile

DEBOJIT ROY

@debojitroy

I am a Dynamic, Software Architect, Full Stack Developer, Techno Managerial leader with 14+ years of experience in building and delivering large scale applications

Discussion

markdown guide