DEV Community

Cover image for Simplify your monorepo with npm 7 workspaces
Łukasz Wolnik
Łukasz Wolnik

Posted on • Updated on

Simplify your monorepo with npm 7 workspaces

Hot reloading for UI React component

This month npm has released a major version of their package manager npm 7. It shipped with support for workspaces.

Why is it big news? Because npm is the only package manager that comes bundled with every NodeJS. To use yarn or pnpm you have to take an additional step and install them first.

Read on and you'll find out how to use npm 7 workspaces in a real-world scenario and learn that using workspaces the npm's way is very different to yarn's.

Monorepo use cases

A monorepo is a term describing a single git repository that contains many projects.

The most common reason to set up a monorepo is to streamline work within a dev team that maintains multiple apps that are using a shared piece of code, for example a common User Interface library.

Imagine a team that develops two React apps that shares some common UI elements like inputs, selectors, accordions, etc. It would be nice to extract that UI in form of React components and prepare building blocks that are ready to use for all members of the team.

Apart from that it's just more convenient to have all your source files opened in a single IDE instance. You can jump from project to project without switching windows on your desktop.

I just want that nice button in my app too

Let's say I want to build two independent React apps called app1 and app2 that will use a common component from a common UI library called ui. And I want both apps to hot reload whenever I edit a file inside the UI library.

By independent I mean that app1 doesn't know anything about app2 and vice-versa.

Below is a setup that is compatible with npm 7 workspaces.

Monorepo structure

Defining workspaces in npm 7

I am prefixing all packages' names with @xyz so they will be distinctive from the official npm registry. Just change it to yours.

This is the most crucial part of the whole setup. Insert below inside your root folder's package.json to set up a monorepo.

{
    "name": "@xyz/monorepo",
    "private": true,
    "version": "1.0.0",
    "workspaces": [
        "./common/*"
    ]
}
Enter fullscreen mode Exit fullscreen mode

The new "workspaces" property lets npm know that I want to track any packages inside ./common folder and automatically symlink them in the root's node_modules when I run npm install.

From now on whenever our React apps will use import Foo from "@xyz/ui" the NodeJS will find it in ./node_modules/common/@xyz/ui that points to ./common/ui folder that contains our library. Perfect! No need for npm link anymore with the workspaces.

Without workspaces the React app would complain that it cannot find a module named @xyz/ui and would start looking for it in the npm official registry.

Take the first step

To test our setup let's share a text from the ui library and import that string into our React app.

Create the common UI library's package.json:

{
    "name": "@xyz/ui",
    "version": "1.0.0",
    "private": true,
    "main": "index.js"
}
Enter fullscreen mode Exit fullscreen mode

and index.js file that will export a string:

const version = "This comes from UI! 1.0.0"

export default version;
Enter fullscreen mode Exit fullscreen mode

Time to import that string into our apps.

To quickly create React apps inside our monorepo. Just use the newly released Create React App 4:

mkdir apps
cd apps
npx create-react-app app1
npx create-react-app app2
Enter fullscreen mode Exit fullscreen mode

Now you'd think that we need to add our ui library to the app. In yarn it would look like below:

yarn workspace app1 add @xyz/ui
Enter fullscreen mode Exit fullscreen mode

But with npm we don't need to add any dependency at all.

Just go to your App.js file in both app1 and app2 apps and add the below code to display a string from our UI library:

...
import testString from "@xyz/ui"; 
...
    <span>{testString}</span>
...
Enter fullscreen mode Exit fullscreen mode

To test it use the below commands:

# create a symlink to the @xyz/ui in the root folder
npm install
# go to the app's folder
cd apps/app1
# For CRA 4 you may need to add SKIP_PREFLIGHT_CHECK=true to .env file
# And use the --legacy-peer-deps flag as many packages hasn't been updated yet to officially support React 17
npm install --legacy-peer-deps
npm run start
Enter fullscreen mode Exit fullscreen mode

and from another terminal window:

cd apps/app2
npm install
npm run start
Enter fullscreen mode Exit fullscreen mode

You'll see the This comes from UI! 1.0.0 text rendered inside both of your React apps!

Export React JSX components

Now if you'd try to export a JSX component the React apps will complain that they cannot parse the JSX. You need to transpile JSX code from the common UI package first.

You can use a basic Webpack 5 setup:

common/ui/package.json

{
    "name": "@xyz/ui",
    "version": "0.2.0",
    "private": true,
    "module": "build/ui.bundle.min.js", # Changed main to module
    "scripts": {
        "build": "webpack --config webpack.prod.js",
        "build-watch": "webpack --config webpack.prod.js --watch",
    },
    ... # webpack 5 dependencies
}
Enter fullscreen mode Exit fullscreen mode

common/ui/babel.config.js

module.exports = {
  presets: [
    [
      "@babel/preset-react",
      {
        targets: {
          node: "current",
        },
      },
    ],
  ],
};
Enter fullscreen mode Exit fullscreen mode

common/ui/webpack.prod.js

const path = require("path");

module.exports = {
  entry: {
    index: { import: "./src/index.js" }
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        loader: "babel-loader",
      },
    ],
  },
  output: {
    filename: "ui.bundle.min.js",
    path: path.resolve(__dirname, "build"),
    // Below two important lines!
    library: 'xyzUI',
    libraryTarget: 'umd'
  },
};
Enter fullscreen mode Exit fullscreen mode

Our simple component:

common/ui/src/index.js

import React from "react";

const UIExample = ({ text = "" }) => {
  return (
    <div>
      <h1>Shared UI library {text}</h1>
    </div>
  );
};

export default UIExample;
Enter fullscreen mode Exit fullscreen mode

Import the UIExample component inside your React app using below:

apps/app1/src/App.js

...
import UIExample from "@xyz/ui";
...
    <div>
        <UIExample text="from app1" />
    </div>
...
Enter fullscreen mode Exit fullscreen mode

Make sure the UI library is transpiled on every code change:

cd common/ui
npm run build-watch
Enter fullscreen mode Exit fullscreen mode

Run the app1 in a separate terminal window and take notice that whenever you edit the UI component the webpack dev server will automatically reload it with the newest version thanks to the webpack watch running in the background.

cd apps/app1
npm run start
Enter fullscreen mode Exit fullscreen mode

Demo

Below I'm editing the common UI component UIElement and upon saving both React apps are automatically refreshed with the updated component:

Hot reload

Summary

With the newest npm 7 and its support of workspaces it is now possible to have a monorepo without a need of any external tools like @react-workspaces or nx.

Just remember that npm has a different philosophy than yarn. For example you cannot run a script inside a workspace from the monorepo's root folder.

Recognize also that @xyz/app1 and @xyz/app2 weren't defined in the monorepo's package.json workspaces property. Only the modules that will be exported need to be there (@xyz/ui).

The npm 7 workspaces are mainly providing discovery for the modules. I wish it had been emphasised in release notes and that the npm's help examples were a bit more complex. I hope this article fills this gap for the time being.

Bonus

Check out my gif-css-animation-monorepo repository that shows how I made the animation for this article using an HTML page.

Discussion (30)

Collapse
raimeyuu profile image
Damian Płaza

Webpack 5 module federation might match npm 7 workspaces nicely. One could evolve apps within monorepo independently, sharing code seamlessly and gain some modularity points when running apps thanks for module federation 😃

Collapse
blowsie profile image
Sam Blowes

Doesnt module federation seem like re-inventing the wheel when we already have ESM?

Vite and Snowpack are already using the ESM approach, I wonder how long WebPack will hold the throne.

Collapse
michalzalecki profile image
Michal Zalecki

They allow you to manage and share code on different levels. Workspaces are for monorepo, you create and import packages as ESM modules. Integration happens in build time with the ability to import modules dynamically, but you need to build the entire app with dynamic imports.

The benefits of module federation are in the runtime e.g. you can update and deploy only a part of your application (e.g. one monorepo package) owned by a separate team. Workspaces and federated modules can work together.

Collapse
limal profile image
Łukasz Wolnik Author

Oh, yes, they should match nicely. Although I have never dared to explore microfrontends to such extent. I guess I would like to see how micro services are working for the back-end systems in the long run first.

In the article I made a Create React App 4's app (based on webpack 4) to import a custom built module (using webpack 5) without issues. It's hardly suprising as in the end it was the webpack 4 that created a single bundle. Nevertheless it feels magical how each piece of the puzzle fit nicely and allowed for hot reloading a separate package. That's what I call sharing code seamlessly.

Collapse
raimeyuu profile image
Damian Płaza

Yes, this feels truly seamless. However, I can imagine that monorepo might bring troubles with CI/CD when there are multiple artifacts produced for each app 🤔

Thread Thread
limal profile image
Łukasz Wolnik Author

That;s true. Although with some planning it doesn't have to take long and produce large Docker image diffs.

The biggest offender is node_modules for each app. Not only it takes long to npm install each of the apps but they also contribute hugely into an image size if not dealt carefully.

  1. Install dependencies for each of the apps by copying just the package.json for each of the app and running their npm install.
  2. Then COPY . . the rest of the files (with node_modules in .dockerignore).
  3. Then build each of the app.

Each consecutive build starts from the step 2 which just copies the source files (not node_modules) and runs the npm build for each of the apps. And that's 10 times faster than installing the dependencies.

You can optimise further and instead of copying all apps together you can order them by their frequence of commits. Placing the most active one as the last step to copy source files and then build.

Thread Thread
raimeyuu profile image
Damian Płaza

Yes, I suppose you're talking about docker layers and writing docker files in the way to reuse as much as possible to not trigger unnecessary operations while building the images.

I was thinking more about multiple teams (so many people) contributing to two apps and queueing builds/deploys many, many times.

Nevertheless, it might be that I'm creating imaginary issues that will never happen :-D

Thread Thread
limal profile image
Łukasz Wolnik Author

Multiple teams can create as many PRs to the monorepo as they want and each commit within the PRs will trigger a new build in a CI. It's not a problem given that builds are so quick thanks to the optimised Dockerfile.

Most of the time each team will update just their app leaving a shared UI code unchanged.

A deployment would only be a problem if the shared code had been modified. Then every app in the monorepo would have to be tested for regressions by a QA team.

But if a newly merged PR contains only code for a single app then it's perfectly safe to deploy it. For only the modified app will create a new CSS/JavaScript bundle.

I am assuming all apps would sit n a single Docker image running from a single nginx cotainer, i.e. example.com/app1 example/app2. It's be possible though to make a separate build of a Docker image for each of the apps and run them in independent containers.

A risk of regression would still be present in this scenario too as updating the shared code alters all artifacts. But that's the trade off of using a shared code/DRY principle in the first place.

Collapse
sashirachakonda profile image
sashiRachakonda

Thanks for this article. I was able to able to follow the article and try out the running example.

trying to apply the same to one of my projects and running into the invalid hook call error...do you have any suggestions ?

Here is the monorepo setup

  • root/
    • /lib1
    • /lib2
    • /create-react-app1

package.json at the root folder designates lib1 and lib2 as workspaces

Thanks,
-Sashi.

Collapse
limal profile image
Łukasz Wolnik Author

Thanks, Sashi.

No idea what's wrong with it, sorry. I have never encountered the issue you're having.

Collapse
sashirachakonda profile image
sashiRachakonda

I believe its caused due to multiple copies (not versions) of React..
I do see multiple copies of react

  • one at the /create-react-app1 level
  • other at the root level. lib1 and/or lib2 require React. That get installed at the root node_modules level.

So, when rendering a view in the CRA, the app thinks there are multiple copies or React and hence the invalid hook call..

Not sure how to resolve this..

Thread Thread
limal profile image
Łukasz Wolnik Author

You're absolutely correct! In fact I had this problem myself but couldn't recognise it at first from invalid hook call.

Add below externals Webpack settings for lib1 and lib2 so that they don't bundle its own React but use whatever create-react-app1 is using.

webpack.common.js

module.exports = {
  ...
  externals: {
    react: {
      root: "React",
      commonjs2: "react",
      commonjs: "react",
      amd: "react",
    },
  },
};
Enter fullscreen mode Exit fullscreen mode
Thread Thread
sashirachakonda profile image
sashiRachakonda

i did add react and react-dom to the externals in lib1 and lib2...some reason, when i do the npm install at the root, i do see react and react-dom in the node_modules folder at the root..

i'll give it another shot...appreciate your comments..

Thread Thread
limal profile image
Łukasz Wolnik Author

No worries. I know it can be tricky.

Maybe try fixing for a single external lib1 and /create-react-app1 firstly. You might be very close to solving this but there's too many moving parts.

Good luck!

Thread Thread
sashirachakonda profile image
sashiRachakonda

If its not much trouble would it be possible to post your example on github ?

Thread Thread
limal profile image
Łukasz Wolnik Author

Good idea. I'll do it this weekend.

Collapse
ruanmartinelli profile image
Ruan Martinelli • Edited on

Hey @limal , I wrote a piece about about npm Workspaces too, here's the link in case it might be useful ✌️ dev.to/ruanmartinelli/what-you-nee...

Very cool gifs by the way!

Collapse
limal profile image
Łukasz Wolnik Author

Hey Ruan. Nice! It's very useful. You gave the topic a lot more background.

Ha! Thanks!

Collapse
jaworek profile image
Jan Jaworski

Do you have any tips on how to set it up with React Native? I find it quite challenging to do it as it involves changing a lot of build settings. I never managed to get it right.

Collapse
limal profile image
Łukasz Wolnik Author

No, not at the moment. I am starting a big React Native project in the coming weeks so I will share my experiences once I figure it out. I don't have high expectations though as I'd rather keep the React Native setup intact so it will be easy to upgrade to the anticipated version 0.64.

Collapse
jaworek profile image
Jan Jaworski

Thanks. I am currently working on two apps that share a lot of components and logic. I have to copy stuff between projects which is time consuming and prone to error. I've been trying to setup monorepo for them but always had some issues with finding native modules or crashes when building so I gave up for now/

Thread Thread
limal profile image
Łukasz Wolnik Author

Totally agree. I'll give it a shot.

Thanks for the heads-up!

Collapse
tinynow_31 profile image
Matt Kreiling

Thanks for this post - there is indeed a need for examples better than npm's docs.
FYI, this post got plagiarized at these two urls:
angrychopshopmaker.tumblr.com/post...
digitalnow878391108.wordpress.com/...

Collapse
limal profile image
Łukasz Wolnik Author

Thanks, Matt! I hope it helps.

Regarding duplicated content. I feel flattered that someone took an effort to copy it. Although those articles look like they were copied by a web scrapper.

Collapse
mkman profile image
Mustafa Mansour

Awesome article, thank you!
I was going through the npm documentation and they seems to indicate that it's possible to run scripts in the workspaces from the monorepo root directory.

Collapse
amos80m profile image
Amos Megidish

Hi, Thank you for this article.
I have a question about the "Apps" section.
once created my "apps" folder and created inside multiple react apps (and everything is working 100%).
how do i "git push" it all(including the new react apps), the git won't upload the apps from the apps folder.
what is the best practice for this, please?

Collapse
limal profile image
Łukasz Wolnik Author • Edited on

Hi Amos,

I think the reason why you can't push all of them is because Create React App is initialising a Git repository inside each of your apps.

Simply remove a .git folder inside the apps and you should be able to have a single repo for all apps.

cd apps/app1
rm -rf .git
Enter fullscreen mode Exit fullscreen mode
Collapse
amos80m profile image
Amos Megidish

Working!, I will need to see how can I manage this with react native apps. you helped me a lot with the start - Thx

Collapse
manojkarmocha profile image
mkarmocha

can this tutorial have another version with typescript ?

Collapse
limal profile image
Łukasz Wolnik Author

Good idea! I'll write a TypeScript version once I find some free time and this time I'll provide also a public repo to easily follow the setup presented here.