loading...
Cover image for TypeScript monorepo for React project

TypeScript monorepo for React project

stereobooster profile image stereobooster Updated on ・5 min read

UPD: with the help from the community some of the problems were resolved. Not all steps in What I did section are updated, but github repo contains all recent changes.

I want to create TypeScript monorepo for React project. I tried and I'm not happy with my result. This post describes what I did. Any advice on how to improve setup? As well there is a small rant in the end. Source code is here.

What I want to achieve

  • Monorepo project, to be able comfortably develop several packages, which can be used separately but as well together
  • with TypeScript
  • for React project
  • with a testing library, I want to start with Jest, but as well we can choose something else
  • with Storybook (or similar tool) for React components development and showcasing
  • (nice to have, but optional) ESlint with eslint-config-react-app
  • (nice to have, but optional) Rollup to bundle and minify
  • (nice to have, but optional) pre-commit hooks with prettier

Packages structure

  • a - utility library
  • b - React components library, which depends on a
  • c - another React components library, which depends on a
  • stories - showcase of b and c package's components as well used for development (initial plan, can change later)

What I did

yarn

yarn instead of npm, because it supports workspaces to link cross-dependencies.

Create package.json in the root without version because we not going to publish it and with workspaces:

"workspaces": [
  "packages/*"
]

lerna

We will use lerna to run commands across all packages and "elevate" common dependencies.

Create lerna.json:

{
  "packages": ["packages/*"],
  "npmClient": "yarn",
  "useWorkspaces": true,
  "version": "0.0.1"
}

TypeScript

We will use typescript to check types and compile TS down to desired JS files (ES5 or ES2015, CommonJS or ES modules).

Create tsconfig.base.json. This is what you need to add to enable monorepo:

{
  "include": ["packages/*/src"],
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "baseUrl": ".",
    "paths": {
      "@stereobooster/*": ["packages/*/src"]
    }
  }
}

Create packages/a/, packages/b/, packages/c/, packages/stories/. Add tsconfig.json to each one:

{
  "include": ["src"],
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    // to override config from tsconfig.base.json
    "outDir": "lib",
    "rootDir": "src",
    // for references
    "baseUrl": "src"
  },
  // references required for monorepo to work
  "references": [{ "path": "../a" }]
}

In package.json for packages b and c add:

"peerDependencies": {
  "@stereobooster/a": "0.0.1"
},
"devDependencies": {
  "@stereobooster/a": "*"
}

We need peerDependencies to make sure that when packages (a, b, c) installed by the end user they will use the same instance of package a, otherwise, TypeScript can complain about incompatible types (especially if use inheritance and private fields). In peerDependencies we specify a version, but in devDependencies we don't need to, because we need simply to instruct yarn to use whatever version of package we have locally.

Now we can build projects. Add to root package.json:

"scripts": {
  "build": "lerna run build --stream --scope=@stereobooster/{a,b,c}"
}

and to package.json for a, b, c

"scripts": {
  "build": "tsc"
}

Problem 1: because of sub-dependencies (packages b and c depend on a, stories depends on a, b, c) we need to build packages accordingly, e.g. first a, second b and c, third stories. That is why we can't use --parallel flag for lerna for build command.

React

Install @types/react, @types/react-dom, react, react-dom.

Add to tsconfig.base.json:

"compilerOptions": {
  "lib": ["dom", "esnext"],
  "jsx": "react",
}

Add to subpackage's package.json:

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

Jest

We will use jest to run tests. Install @types/jest, @types/react-test-renderer, jest, react-test-renderer. Add jest.json. To eanbale TypeScript:

{
  "moduleFileExtensions": ["ts", "tsx", "js"],
  "transform": {
    "\\.tsx?$": "ts-jest"
  },
  "testMatch": ["**/__tests__/**/*.test.*"],
  "globals": {
    "ts-jest": {
      "tsConfig": "tsconfig.base.json"
    }
  }
}

to enable monorepo:

"moduleNameMapper": {
  "@stereobooster/(.*)$": "<rootDir>/packages/$1"
}

As well we will need to change tsconfig.base.json, because Jest doesn't support ES modules:

"compilerOptions": {
  "target": "es5",
  "module": "commonjs",
}

Add command to package.json

"scripts": {
  "pretest": "yarn build",
  "test": "jest --config=jest.json"
}

Problem 2: we will publish modules as ES5 + CommonJS, which makes no sense for React package, which would require some kind of bundler to consume packages, like Parcel or Webpack.

Problem 3: there are sub-dependencies, so we need to build all packages first and only after we can run tests. That is why we need pretest script.

Storybook

Install storybook according to official instruction.

We will need the following things in package.json:

"scripts": {
  "start": "start-storybook -p 8080",
  "build": "build-storybook -o dist"
},
"dependencies": {
  "@stereobooster/a": "*",
  "@stereobooster/b": "*",
  "@stereobooster/c": "*"
},
"devDependencies": {
  "@babel/core": "7.4.3",
  "@storybook/addon-info": "^5.0.11",
  "@storybook/addons": "5.0.6",
  "@storybook/core": "5.0.6",
  "@storybook/react": "5.0.6",
  "@types/storybook__addon-info": "^4.1.1",
  "@types/storybook__react": "4.0.1",
  "awesome-typescript-loader": "^5.2.1",
  "babel-loader": "8.0.5",
  "react-docgen-typescript-loader": "^3.1.0"
}

Create configurations in .storybook (again, based on official instruction). Now we can create stories in /src/b for b packages, in /src/c for c package.

Storybook will watch for changes in stories/src, but not for changes in a/src, b/src, c/src. We will need to use TypeScript to watch for changes in other packages.

Add to package.json of a, b and c packages:

"scripts": {
  "start": "tsc -w"
}

and to the root package.json:

"scripts": {
  "prestart": "yarn build",
  "start": "lerna run start --stream --parallel"
}

Now a developer can run yarn start (in one terminal) and yarn test --watch (in another terminal) to get development environment - scripts will watch for changes and reload.

Problem 3: there are sub-dependencies, so we need to build all packages first and only after we can run the start script. That is why we need prestart script.

Problem 4: If there is type error in stories it will show up in the browser, but if there is type error in a, b or c packages it will only show up in terminal, which spoils all DX, because instead of switching between editor and browser you will need to switch to terminal as well to check if there is an error or not.

Rant

So I spent quite some time (half of a day?) to figure out all the details and the result is disappointing. Especially I disappointed by problem 2 and problem 4. Even more, I didn't write a line of actual code. It is so frustrating that the JS ecosystem doesn't appreciate convention over configuration principle more. We need more create-react-apps and Parcels in the ecosystem. Tools should be built with cross-integration in mind.

There is probably the solution to the problem, maybe I need to try ava and esm to fix problem 2, but I'm so disappointed that I spent all that time to fight with incidental complexity. Instead, I decided to pause and write the post.

Photo by Ruby Schmank on Unsplash

Posted on by:

stereobooster profile

stereobooster

@stereobooster

Hello, I'm a full stack web developer. Follow me on Twitter!

Discussion

markdown guide
 

blog.nrwl.io/powering-up-react-dev...

While it was built for Angular, it does support React projects as well.

I'm not sure if this will solve all of the problem above, but this is worth giving a shot.

 

Indeed, take a look at nx.dev/react/fundamentals/build-fu...

  • Monorepo
  • Typescript
  • Build tools / control
  • Storybook
  • Jest
  • Cypress
 

You might want to take a look at Apollo monorepo setup: github.com/apollographql/apollo-se....
In particular, you can address "problem 1" by adding "composite": true and "references" to tsconfig.json in packages, so Typescript compiler will take care of building in the right order. This requires Typescript 3.0+ (typescriptlang.org/docs/handbook/p...).
Problem 2 will go away if you use different tsconfig for testing.

 

Yep, people already point this out github.com/stereobooster/typescrip.... All problems resolved.

Problem 2 can't be solved gracefully with different config, because we need to run build before running tests, so it will get confusing. Instead I added babel to process ES6 modules

 

I know what you mean about it being time consuming. I think I spent much more than a half a day...

But at least for my use case, a library of npm packages in Typescript, I either solved or didn't have most of the problems you mention.

Problem 1: I did have this one and your solution (not using parallel) was what I ended up with. Not sure if there's a better solution unless Lerna came up with an api that lets you scope packages in groups (with separate concurrency settings).

Problem 2: I ended up having Webpack handle Typescript and output as UMD in each of the packages. This works, but results in bigger bundles so I want try switching to using Babel.

Problem 3: didn't have this problem, probably because of Webpack. I use Jest (with ts-jest) without having to build first, in watch mode, etc. with no issues.

Problem 4: I did get Storybook working ok, but due to some unrelated issues with data I opted to roll my own demo app. I can't remember if it had the cli vs browser issue with type errors, it might have.

One issue I do have that I haven't solved is hot module replacement. I didn't put much time into it, but wasn't able to get it working with my setup. Standard reload does work at least.

Honestly, I agree it would be nice if this could all get simpler. But I'm excited that it's possible. Aside from UI libraries it's a great way to share isomorphic code, common code between React Native and a web app, etc. Not to mention the disk space savings. For me, I see it as one of those things that's a bit rough on the edges now but will hopefully improve with time.

I would take this repo for a grain of salt, as it's my first attempt at a monorepo (and some things like name choices were done pretty hastily): github.com/unleashit/npm-library

 

I'm in the middle of working through some of the same problems. Here's the repo:

github.com/good-idea/sane-shopify

For problem #2: I'm using Rollup to build the packages, and it has been very painless so far. This was a helpful article: medium.com/@ali.dev/how-to-publish...

 
 

You claim to "appreciate convention over configuration" but you are trying to accomplish a very "unconventional" setup. Doesn't make sense

 

What is your suggestion for a more conventional approach?

 

Hi, first thanks a ton for spending your time to test figure out stuff even writing and publish it.
Just in case you wonder, I found article react lerna with typescript here. medium.com/@sisosys7/a-monorepo-se...

probably you may had seen it, but I can run it successfully following the steps there.
Peace :)

edit : after checking here and there, the source that I put is just a minimal how react + typescript + lerna.
It does not have tests, yarn workspaces, and probably more.
I also check in your github repo for this monorepo things and i can see many progress here and there how to fix the Problems above.

Thank you for your post.

 

Cool article! Thanks. In turn, I have something that worth to check - webman.pro/blog/how-to-setup-types...
In the article, I covered the aliases related issues we can face with configuring Typescrit/React/Node.js/Lerna monorepo.

 

@stereobooster thanks for this article! 👍

It helped me with a project. I created an account on dev.to solely to like your article and leave this comment!

 

Thanks. Yes, it took some time (more than I expected). When I initially shared the project I didn't have all the process. Community helped to figure out some questions.

To be fair it is unstable - in my current project TypeScript errors don't show up in Storybook :facepalm: (but they did before)

 

Great read. Where I've got the wall personally is with sass modules.

I've ended up with huge webpack bundles. That's hardly developer friendly.