Working with React in monorepository
Vitaly Rtishchev
Posted on October 12, 2020
TL; DR Jump straight to code. Monorepository will allow you to organize your react project, isolate and test components/hooks/other application parts with benefits of shared tools (eslint, webpack, prettier) configurations.
Note: This article covers frontend only monorepositories.
Why
Imagine that you have a design system and several react applications that depend on it. How it looks without monorepo:
Develop design system separately and publish it, for example, as npm library with react components – setup storybook, webpack, eslint, other tools configuration for design system
Create new repository for application that depends on design system, setup storybook, webpack, eslint, other tools configuration for this application (repeat this step each time you need new application)
Support design system and all applications separately – update all tools and dependencies in each project
Hire new developer and explain each application individually
This is fine when you have one application, but when you start working with multiple applications, it will become a real copy paste hell.
Monorepository will allow you to:
keep all repetitive logic in one place and share it between all applications and packages
manage all related projects from one repository – this will allow to give full access to all projects for new developers who join your team
reuse development tools (webpack, eslint, jest, etc.) configurations in all packages
achieve greater code reuse – apart from design system example, you can extract, your hooks library to separate package
create new applications and packages without need to setup boilerplate code
keep all application in sync with design system updates without need to update dependencies
How
You can find full example in react-monorepo-starter.
Project structure
Bare minimum project structure with shared tools configuration – scripts, storybook and webpack folders contain all shared logic used in all apps and packages.
.
├── scripts/
│ ├── build-package.js
│ └── start-app-dev-server.js
├── storybook/
│ ├── main.js
│ └── start.js
├── webpack/
│ ├── .babelrc.js
│ ├── get-app-config.js
│ ├── get-package-config.js
│ └── loaders.js
├── src/
│ ├── packages/
│ │ └── ui/ –> @monorepo/ui
│ │ ├── src/
│ │ │ ├── index.js
│ │ │ └── Button/
│ │ │ └── Button.jsx
│ │ ├── package.json
│ │ └── webpack.config.js
│ └── apps/
│ └── hello-world/ -> @monorepo/hello-world
│ ├── src/
│ │ └── index.jsx
│ ├── package.json
│ └── webpack.config.js
├── .eslintrc.js
├── .prettierrc.js
└── package.json
Setting up webpack builds
To setup webpack we will need only one unusual tweak – automatic aliases generation – this will allow us to resolve packages and apps src directories.
For example, for @monorepo/hello-world
app we will need @monorepo/ui
alias that will point to src/packages/ui
.
This is done pretty easy, you can find all logic here
We will need two separate webpack configurations – first for package, second for apps. They share the same loaders so we need to extract loaders to separate file to reuse them in both configurations.
// loaders.js
// babel loader example
const path = require('path');
const babelrc = require('./.babelrc');
const babel = () => ({
test: /\.(js|jsx)$/,
exclude: /node_modules/,
include: path.join(__dirname, '../src'),
use: {
loader: 'babel-loader',
options: babelrc, // babelrc is loaded directly with webpack
},
});
All other parts of webpack configuration stay the same like in any other projects with one difference – we need to wrap everything with function to generate webpack config for each app and package:
// get-pacakge-config.js
const fs = require('fs-extra');
const path = require('path');
const webpack = require('webpack');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const TerserJSPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const loaders = require('./loaders');
const getPackageAlias = require('../scripts/utils/get-package-alias');
module.exports = function getPackageConfig({ base, publicPath = '/' } = {}) {
const { name } = fs.readJsonSync(path.join(base, './package.json'));
return {
mode: 'production',
devtool: false,
entry: path.join(base, './src/index'),
optimization: {
minimizer: [new TerserJSPlugin({}), new OptimizeCSSAssetsPlugin({})],
},
resolve: {
extensions: ['.js', '.jsx'],
alias: {
...getPackageAlias(name),
},
},
module: {
rules: [loaders.babel(), loaders.less({ mode: 'production', publicPath }), loaders.file()],
},
plugins: [
new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production') }),
new MiniCssExtractPlugin(),
],
};
};
Then we will be able to reuse webpack configuration in package with single line:
// @monorepo/ui package -> src/packages/ui
const path = require('path');
const getPackageConfig = require('../../../webpack/get-package-config');
module.exports = getPackageConfig({ base: path.join(__dirname, './') });
Shared Storybook
I use storybook for ui development so we need to setup it to work with monorepository.
There is one challenge – when monorepo grows large storybook will get slower and slower, so we need to cut out stories that are not currently developed. To achieve that we can simply start storybook with only packages and apps that we plan to edit. For example, to run storybook with two packages – npm run storybook @package/first @package/second
To do this, we will need to parse packages directories and cut out unused (almost the same as with webpack aliases). You can find full logic here.
Just the core logic to filter packages:
// storybook/main.js
const DEFAULT_STORIES = ['../src/**/*.story.@(jsx|mdx)'];
const packages = argv._;
let stories = DEFAULT_STORIES;
if (packages.length !== 0) {
stories = [];
packages.forEach(packageName => {
const packagePath = getPackagePath(packageName);
if (packagePath) {
stories.push(path.join(packagePath, 'src/**/*.story.@(jsx|mdx)'));
} else {
process.stdout.write(chalk.yellow(`Warning: Unable to resolve ${packageName}, skipping\n`));
}
});
}
Shared build and start scripts
To reduce boilerplate code we need to create shared build and start scripts that will allow to build and start packages from the repository root. Same as above it is done with src directory parsing. You can find full code here
With this script we are able to build and startup applications like this:
-
npm start @application/name
– start application -
npm run build @package/name
– build single package -
npm run build @package/first @package/second
– build list of packages
Ready for development
Now we are fully ready for development: we can develop components in packages with storybook and use them in applications with webpack aliases. Example:
// example with included @monorepo/hello-world app
import React from 'react';
import { Text } from '@monorepo/typography';
import Button from '@monorepo/ui/Button/Button';
export default function App() {
return (
<div>
<Text style={{ marginBottom: 20 }}>Welcome to monorepo starter</Text>
<Button>Hello</Button>
</div>
);
}
Posted on October 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 24, 2024