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 inapps/
.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 inpackages/
or public packages likereact
,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 allpackage.json
file in the repo. - All
package.json
files in the repo have“version”: “0.0.1”
. - All
packages/
orapps/
in the repo have the same namespace. So you can choose your own namespace. In this example, I choose@namespace/
devDependencies
: the rootpackage.json
file contains all development dependencies so we don’t need to re-declare development dependencies in childrenpackage.json
file. You may wonder why there arereact
andreact-dom
indevDependencies
. That is a good question! We need to installreact
andreact-dom
at root, so Jest tests inside childrenpackages/
can findreact
andreact-dom
because childrenpackages/
should not depend on big libraries (react
andreact-dom
in this case ) directly.dependencies
: the rootpackage.json
file contains no production dependencies. Production dependencies are declared in childrenpackage.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
inpeerDependencies
. You can add big libraries which the sub-package is using inpeerDependencies
as well. Please notice thatapps/
should not havepeerDependencies
because the app is the end of the chain. devDependencies
: I move all development dependencies to the rootpackage.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 indevDependencies
. If you miss declaring dependencies in thepackage.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 helpswebpack
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 ofmain:src
:- + Webpack to be able to do hot/real-time compiling in Webpack dev server.
- + We don’t need to compile all
packages/*
packages todist
orbuild
folder since we instruct Webpack to lock atpackages/*/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 --fix
to 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 runyarn 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 fileconfig/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 thistest
command which is used to run tests of all repo. So you can useyarn 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 runningyarn 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 onlyyarn.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 runyarn install
at the root folder later. - Update
apps/my-app/tsconfig.json
file to re-use the content oftsconfig.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 updatewebpack.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 runyarn install
at the root folder later. - Delete
apps/my-app/package-lock.json
file because we use Yarn and already haveyarn.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
andmain
in childrenpackages/*/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/
andpackages/
folders to compile TS/JS. So adding 2 new propspackagesSrc
andappsSrc
:
// 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
…) topackages/*
orapps/*
, make sure you add it todependencies
ordevDependencies
of rootpackage.json
and then adding itdependencies
ordevDependencies
ofpackage.json
file in packages/apps scope. - Make sure the version of a library is consistent in all
packages.json
files. For example, you havelodash: “^.17.19”
in onepackage.json
file and you should make surelodash
in anypackage.json
files in your workspace to use the samelodash: “^.17.19”
. You can use https://github.com/boltpkg/bolt which is a wrapper ofyarn
.bolt
helps to keep versions of libraries in your workspaces consistently and brings more advanced benefits of usingyarn
.
Tips
- After you do something which changes
yarn.lock
a lot, make sure you remove allnode_modules
folder by running this shortcut commandyarn clean:all
at root. Please seescripts/clean.sh
to know-howyarn 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!