React Project - Idea to Production - Part Two - Setting up a Component Library
Tue May 19 202016 min read
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
- Part One : Wireframes and Project Setup
- Part Two : Setting up a Component Library
- Part Three : Building the Movie App using component library
- Part Four: Hosting the Movie app and setting up CI/CD
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
We are getting there slowly #😊
Now we need to generate json
output for storybook to consume and show next to our stories. Lets 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
We will add couple of more commands for building storybook for deployment. We will be using these when we configure Continous 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