Working with React in monorepository

rtivital

Vitaly Rtishchev

Posted on October 12, 2020

Working with React in monorepository

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
Enter fullscreen mode Exit fullscreen mode

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
  },
});
Enter fullscreen mode Exit fullscreen mode

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(),
    ],
  };
};
Enter fullscreen mode Exit fullscreen mode

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, './') });
Enter fullscreen mode Exit fullscreen mode

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`));
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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

Alt Text

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
rtivital
Vitaly Rtishchev

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