CRA and Yarn Workspace

Tung Dang
11 min readAug 8, 2021

--

Recently, I wanted to set up a Typescript Create React App (CRA)in a Yarn workspace for my pet projects. I have gone through many articles to guide me on how to set up what I want. It takes a lot of time to set up since CRA does not support well with Yarn Workspace. Therefore, I wanted to set up a Github example repo and write a document about solutions I found. You can see the repo example in this Github link: https://github.com/tung-dang/cra-yarn-worspace-example. The example uses:

  • Typescript
  • Create React App to set up a web app
  • Yarn workspace. Don’t need to use and setup Lerna
  • Jest + eslint

Benefits of Yarn Workspace

You may know some benefits are already mentioned on the official Yarn website. In addition, I wanted to add my personal opinions about the advantages of using a workspace.

  • I can split a big app into many logical modules/packages. For example, I can have import { ... } from @Mycompany/moduleA it in the web app.
  • @Mycompany/moduleA does not need to be published to a public/private NPM registry to make our app work normally.
  • In the future, we can decide to decompose certain parts of the monolith/workspace more easily. For example, I can move @Mycompany/moduleA into a separated repo and decide to open source it, then we don’t need to change our web app to reference the new public package @Mycompany/moduleA .

Setup the development environment

Yarn

We will use yarn version 1.22.10. I have not tried other yarn versions, so please report the issue if you find any issue with the other yarn versions.

Node version

You know in software development, it’s quite annoying to debug issues if we don’t use the same versions of libraries/dev environment. So we will setup nvm to make sure we always use the correct Node version declared in .nvmrc file at the root folder.

Content of .nvmrc file:

v14.17.3

Before running any commands in the repo, make sure you run nvm use to use the correct node version. The first time, you may need to run nvm install to download the Node version required in .nvmrc

Setup the Yarn Workspace

Here is a simplified folder structure.

.
├── apps
│ └── cra-example-app-1
│ | ├── README.md
│ | ├── jest.config.js
│ | ├── package.json
│ | ├── src
│ | └── tsconfig.json
├── packages
│ └── common
│ | ├── README.md
│ | ├── jest.config.js
│ | ├── package.json
│ | ├── src
│ | └── tsconfig.json
│ └── awesome-react-component
│ ├── README.md
│ ├── jest.config.js
│ ├── package.json
│ ├── src
│ └── tsconfig.json
├── package.json
├── tsconfig.json
└── yarn.lock

We have 2 main folders which contain sub-packages/apps

  • packages/ : contains NPM packages. These packages do not need to be published in a public/private NPM registry. You can publish these packages to the world if you want. Publish the NPM package is not necessary to build the app in apps/.
  • apps/ : contains web app. Each web application needs to have a Webpack config to build a web app. The web app can import other internal packages in packages/ or public packages like react , lodash

The root `package.json` file

{
"name": "@namespace/root",
"version": "0.0.1",
"private": true,
"workspaces": { "packages": [
"packages/*",
"apps/*"
]
},
"scripts": {
...
},
"dependencies": {
...
},
"devDependencies": {
...
},
"engines": {
"node": ">=14.17.3",
"npm": ">=6.12.0",
"yarn": "^1.17.3"
}
}

Because we don’t need to publish to the root package.json to the world, so

  • We set ”private”: true. Basically, we don’t need to publish any packages to a public/private NPM registry so there is ”private”: true in all package.json file in the repo.
  • All package.json files in the repo have “version”: “0.0.1” .
  • All packages/ or apps/ in the repo have the same namespace. So you can choose your own namespace. In this example, I choose @namespace/
  • devDependencies : the root package.json file contains all development dependencies so we don’t need to re-declare development dependencies in children package.json file. You may wonder why there are react and react-dom in devDependencies . That is a good question! We need to install react and react-dom at root, so Jest tests inside children packages/ can find react and react-dom because children packages/ should not depend on big libraries (react and react-dom in this case ) directly.
  • dependencies : the root package.json file contains no production dependencies. Production dependencies are declared in children package.json files.

We follow this page to set up a workspace:

"workspaces": {
"packages": [
"packages/*",
"apps/*"
]
},

Children `package.json` files

Each sub-folder in packages/ and apps/ has a package.json file whose content looks like this:

{
"name": "@namespace/common",
"version": "0.0.1",
"private": false,
"description": "An package contains common functions",
"main": "dist/index",
"module": "dist/index",
"main:src": "src/index",
"types": "dist/index.d.ts",
"scripts": {
"clean": "../../scripts/clean.sh",
"lint": "eslint '**/*/*{ts,tsx}'",
"test": "jest",
"build": "yarn clean && ../../scripts/build-package.sh"
},
"peerDependencies": {
"react": "^17.0.2",
},
"dependencies": {
},
"devDependencies": {
}
}
  • We don’t need to publish it anywhere so we should have “private”: false and “version”: “0.0.1” .
  • All children packages/apps in the repo have the same prefix namespace in name field, e.g: @namespace/xxx .
  • If the current package has React components, you should declare react in peerDependencies . You can add big libraries which the sub-package is using in peerDependencies as well. Please notice that apps/ should not have peerDependencies because the app is the end of the chain.
  • devDependencies: I move all development dependencies to the root package.json file so we don’t need to redeclare it here. However, if you want to migrate the children package into a separated repo and publish it, make sure you declare all used development dependencies in devDependencies . If you miss declaring dependencies in the package.json file, consumers will fail to use your published package or other devs cannot join to contribute the new repo.
  • dependencies: I think it’s important to add used production dependencies here. So we can know how many dependencies the current package is using. If the number of dependencies is so big, it’s a good indicator to split into current package into multiple smaller packages.
  • main:src: is our custom field which helps webpack of the web app can watch and re-compile in local development. main:src field is not official/supported by NPM docs. Here are some benefits of main:src :
  • + Webpack to be able to do hot/real-time compiling in Webpack dev server.
  • + We don’t need to compile all packages/* packages to dist or build folder since we instruct Webpack to lock at packages/*/src/* to compile a web app for us.

Setup linting/testing

Prettier

We love code style consistency so that my IDEA (VScode or Webstorm) to auto-format code style when saving or committing codes in git. That is the reason there are 2 configs files

  • .prettierrc.js
  • prettieringore

Content of .prettierrc.js

module.exports =  {
semi: true,
trailingComma: 'all',
singleQuote: true,
printWidth: 120,
tabWidth: 2,
};

You can see more options of Prettier in this file https://prettier.io/docs/en/options.html

Linting

Most FE Repos nowadays use eslint to catch potential issues in the code. We have one eslint config file called .eslintrc.js at the root folder. The rules in .eslintrc.js may not suit your project so you can add/remove eslint plugins/rules to make it suit your company project.

We use husky and lint-staged to help us to run eslint --fixto fix issues automatically when committing these *.{js,jsx,ts,tsx} files. You can ignore files that eslint can run when committing files in lint-staged.config.js at root folder.

const micromatch = require('micromatch');module.exports = {
'*.{js,jsx,ts,tsx}': (files) => {
const match = micromatch(files, [
'*',
// ignore these files
'!*babel.config*',
'!*jest.config*',
'!*jest.setup*',
'!*eslintrc*',
'!*lint-staged*',
'!__jest__',
'!__mocks__',
'!.storybook',
'!.husky',
]);
return `eslint --fix ${match.join(' ')}`;
},
};

At root package.json, there is this lint command which is used to run eslint for all children packages/apps. So we can run yarn lint command at the root folder.

"scripts": {
"lint": "yarn workspaces lint",
}

At children package.json files, there is this lint command which is used to run eslint for the current package/app folder. So we can run a yarn lint command at sub-packages/apps scope.

"scripts": {
"lint": "eslint '**/*/*{ts,tsx}'",
}

Cleaning stuff

When building/compiling, it is important to clean any previously generated folders/files to make sure we start from a clean slate. So there is a bash script file scripts/clean.sh to do that job.

At root package.json, there is the command to run it:

"scripts": {  "clean": "./scripts/clean.sh && yarn workspaces run clean",  "clean:all": "./scripts/clean.sh all",},
  • Running yarn clean at the root folder, it will run yarn clean for all sub-packages/apps folders.
  • clean:all : help to remove all generated files/folders + node_modules at root and children packages/apps.

At children package.json files, there is the same command.

"scripts": {  "clean": "../../scripts/clean.sh",},

Running yarn clean at the children packages/apps folder, it will just clean generated files/folders of that specific package.

# For example, current folder is "packages/common"yarn clean# will delete all generated files inside "packages/common" folder only.

Unit test — Jest

We use Jest to write unit tests. In order to Jest run correctly, there are 2 important files:

At the root folder, there is a shared Jest config file called jest.config.js at root folder. It contains logic to help the Jest test can run and import the code from packages/xxx/src/* in the workspace.

const PACKAGE_NAME_SPACE = '@namespace';
const ROOT_REPO = path.resolve(__dirname);
const ROOT_JEST_CONFIG_FOLDER = `${ROOT_REPO}/config/jest`;
// get listing of packages in the mono repo
const PACKAGE_FOLDER = `${ROOT_REPO}/packages`;
const packages = readdirSync(PACKAGE_FOLDER).filter((name) => {
const packageFolders = path.join(PACKAGE_FOLDER, name);
return lstatSync(packageFolders).isDirectory();
});
const moduleNameMapper = {
...packages.reduce(
(acc, name) => ({
...acc,
[`${PACKAGE_NAME_SPACE}/${name}(.*)$`]: `${PACKAGE_FOLDER}/./${name}/src/$1`,
}),
{},
),
};
module.exports = {...
moduleNameMapper: {
'^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy',
...moduleNameMapper,
},
...
};
  • jest.config.js at root folder also points to the shared Jest setup file config/jest/setupTests.ts .
// in `jest.config.js` at root folder
module.exports = {
...
setupFilesAfterEnv: [`${ROOT_JEST_CONFIG_FOLDER}/setupTests.ts`],
...
};
  • In root package.json, there is this test command which is used to run tests of all repo. So you can use yarn test to run all tests in the workspace.
"scripts": {
"test": "TZ=UTC yarn workspaces run test --watchAll=false --passWithNoTests"
}

At children packages/ and apps/, there is alsojest.config.js which uses the shared setup jest file at the root.

// in `packages/common` folder
const base = require('../../jest.config');
const packageJson = require('./package');
module.exports = {
...base,
name: packageJson.name,
displayName: packageJson.name,
};
  • In children package.json , there is this atest command which is used to run tests of the current package/app folder scope. So you can docd packages/<sub-folder-name> and then running yarn test to run all tests in a sub-package.
"scripts": {
"test": "jest",
}

CRA inside Yarn workspace

Now we can generate an app in apps/ via CRA command. You can use this command to generate a Typescript CRA app npx create-react-app my-app — template typescript . However, if you use that command inside the yarn workspace repo, there is an issue. So my workaround is to use the command in a temporary folder somewhere and copy the my-app in to apps/ folder of the yarn workspace, then do the following steps:

  • Delete apps/my-app/yarn.lock file because one repo should have only yarn.lock file at the root.
  • Delete apps/my-app/.git folder because one repo should have .git at root.
  • Delete apps/my-app/node_modules folder. We will run yarn install at the root folder later.
  • Update apps/my-app/tsconfig.json file to re-use the content of tsconfig.json at root.
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "./build",
"jsx": "react-jsx",
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"scripts/**/.ts"
],
"exclude": [
"node_modules",
"build",
"dist",
"**/*/*tests*"
]
}
  • Update apps/my-app/package.json file to have the namespace as other packages, e.g: “name”: “@namespace/my-app
  • Run yarn eject so that we can update webpack.config.js of CRA app manually. If someone knows how to configure the Webpack config of CRA without ejecting, please let me know.
  • Delete apps/my-app/node_modules folder again. We will run yarn install at the root folder later.
  • Delete apps/my-app/package-lock.json file because we use Yarn and already have yarn.lock at the root.

Update `webpack.config.js`

  • Here we are using Typescript so we don’t need to check the existence of tsconfig.json file.
// in `apps/my-app/config/webpack.config.js` file, change:// from: 
const useTypeScript = fs.existsSync(paths.appTsConfig);
// to:
const useTypeScript = true;
  • As mentioned above, there are these fields main:src, module and main in children packages/*/package.json. So we need to instruct Webpack of an app to find paths in one of those files to compile code via mainFields
// in `apps/my-app/config/webpack.config.js` file, add new `mainFields` config:// from: 
resolve: {
...
extensions: paths.moduleFileExtensions,
...
}
// to:
resolve: {
...
extensions: paths.moduleFileExtensions,
mainFields: ['main:src', 'module', 'main', 'browser'],
...
}
  • We need to instruct Webpack to look at apps/ and packages/ folders to compile TS/JS. So adding 2 new props packagesSrc and appsSrc :
// In `apps/devbox-app/config/paths.js`module.exports = {
...
packagesSrc: resolveApp('../../packages'),
appsSrc: resolveApp('../../apps'),
};

Then adding both apps/ and packages/ folders inside resolve.module.rules

// in `apps/my-app/config/webpack.config.js` file, update this:// from:
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
// to:
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: [
paths.appSrc,
paths.packagesSrc,
paths.appsSrc
],
}
  • Now we want to test my-app to import code from other packages in /packages
// in `apps/my-app/src/App.tsx` file...import AwesomeComponent from '@namespace/awesome-react-component';
import { log } from '@namespace/common';
function App() {...
return (
<div className="App">
<header className="App-header">
...
<AwesomeComponent />
</header>
</div>
);
}
export default App;
  • Finally, you can start the CRA by:
cd apps/my-app
yarn start

You can create a short command in the root package.json file so you don’t need to cd into apps/my-app .

Adding new libraries

  • When you want to add a library (e.g. lodash…) to packages/* or apps/* , make sure you add it to dependencies or devDependencies of root package.json and then adding it dependencies or devDependencies of package.json file in packages/apps scope.
  • Make sure the version of a library is consistent in all packages.json files. For example, you have lodash: “^.17.19” in one package.json file and you should make sure lodash in any package.json files in your workspace to use the same lodash: “^.17.19” . You can use https://github.com/boltpkg/bolt which is a wrapper of yarn . bolt helps to keep versions of libraries in your workspaces consistently and brings more advanced benefits of using yarn .

Tips

  • After you do something which changes yarn.lock a lot, make sure you remove all node_modules folder by running this shortcut command yarn clean:all at root. Please see scripts/clean.sh to know-how yarn clean:all works.

Last words

I know it’s a lengthy article but I hope this article can save someone’s time to find a way to set up Typescript CRA with Yarn Workspace. Please comment on your feedback so I can continue to improve this article. Thank you for your reading!

--

--