Protect react codebase with Eslint, Prettier, Typescript, Lint-staged and husky.
The goal here is to try to eradicate the run-time exceptions.
Resources
Repository: github.com/karthickthankyou/epic-clone
// Clone start branch:
git clone -b 01-static-testing-start github.com/karthickthankyou/epic-clone// Clone final branch:
git clone -b 01-static-testing-final github.com/karthickthankyou/epic-clone
Why?
And that’s the point. We don't want our application to even build if there are issues in it.
Runtime errors vs compile time errorsa. The application breaks as our users are using it.b. The application breaks as our developers are typing the code.
Static checking helps us to catch a significant amount of possible run time errors at compile time.
We are going to employ Typescript and Eslint to catch as many issues as early as possible. That saves so many issues in the production.
Elm’s claim
Elm, a programming language boasts ‘No Runtime Exceptions’ on its home page. “Elm uses type inference to detect corner cases and give friendly hints.”
Elm’s bold claim made me think, ‘Yes, that's how development should be!’. If something goes wrong, The developer should know right in the editor while typing it. That will save a huge amount of time and frustration.
Our goal here is to create the below condition.
The application will run if it compiles. But the compiler wont get satisfied that easy!
We are going to employ eslint and typescript to convert as many runtime errors into compile-time errors.
Note:
We used create-react-app to bootstrap our application using the template that configured typescript for us. Also, create-react-app by default brings eslint as an inner-dependency. So, we don’t have to install anything for typescript and eslint. If you are not using CRA, I suggest you install eslint and typescript on your own.
Prettier
Install the VScode extension: Prettier — Code formatter by Prettier.
Now, you can go to the user settings in VSCode (CMD+SHIFT+P and type user settings) and configure things like the semicolon, single vs double quotes, etc to suit your taste.
That would be sufficient if you are the only one working on the project. But we need a project-level format configuration if you have other teammates and contributors.
Why? Imagine, your teammate loves semicolons, and you happened to hate them. When the teammate updates your code and raises a PR, all the newly added semicolons added by his/her local prettier configuration will show up as changes. That PR will become impossible to review.
So, as a team, we will come up with the formating decisions and enforce that in the repository.
Step 1: Install prettier as a development dependency on your project.
npm install -D prettier
Step 2: Configure prettier.
// Create a file named .prettierrc.json in the project's root // // // directory.echo {}> .prettierrc.json
Step 3: Generate prettier rules
Instead of typing the configurations ourselves, open prettier.io/playground and select the necessary configurations you need in the left bar. I checked — single-quote and, — no-semi and left everything else as it is.
Click the button Copy config JSON in the bottom and paste in the .prettierrc.json file. My file looks like below.
Step 4: Check the format of all files.
Run the below command to get a list of all unformatted code in the project.
npx prettier --check .
I got the below response. Prettier has thrown me warnings with the file names that aren’t formatted.
Step 5: Fix the format
If you have the Format On Save option enabled in VSCode user settings, you can open each file and save the file. That will automatically format the file.
Format a few files in that way and run the above check command. The files you formatted won't be shown!
Ok. Now, to fix the format issues of all the unformatted files, run the below command with the — write flag.
npx prettier --write .
Instead of just warning about the unformatted code, This will go ahead and formats the files for us!
Now run the check command again to see the success message as below.
Cool.
Step 6: Turn them into npm scripts.
Add the below in the package.json under the scripts property along with the start, test, and build scripts.
"format:check": "prettier . --ignore-path .gitignore --check", "format:fix": "prettier . --ignore-path .gitignore --write",
Now, we can run format: check/fix commands to utilize the prettier that we locally installed as dev dependency instead of pulling it from npx.
Now, you can run the below command to check and write in the git hooks and ci/cd pipelines.
npm run format:checknpm run format:fix
We are pretty much done with prettier but the above scripts check and fix are redundant. We can write them as follows.
"prettier": "prettier . --ignore-path .gitignore",
"format:check": "npm run prettier -- --check",
"format:fix": "npm run prettier -- --write"
It is a bit cleaner. We can use “ — ” to add new arguments with the existing command.
That is it for prettier for now. We will be revisiting these commands when we set up githooks. We will use the pre-commit hook to automatically fix the format issues when someone commits the code.
Eslint
As we already have eslint as an inner-dependency from react-scripts, We can run the below script.
// lint only the extensions tsx and ts."lint": "eslint --ext .tsx,.ts ."
and run the command.
npm run lint
That threw a no-empty pattern warning as shown below.
But we need a much stricter eslint to catch as many coding problems as we can in compile time.
Let's initialize eslint to create a much stricter configuration. It is going to be a fun questionnaire.
npx eslint --init
Why not? Let's pick all three!
Here we can choose the second option and set a custom configuration. But I’m going to go for Airbnb for their 100,000+ stars.
That’s it. That installs the necessary dependencies and creates an eslintrc.js file. Run the npm run lint command.
Wow. This strict configuration we just set up found 155 problems with our tiny project. Let's fix one by one.
Prettier plugin: Huge number of errors are saying missing semicolon. What? We just set up prettier to have to semicolon. Should we set up eslint too? No. We can ask eslint to work with prettier with eslint-config-prettier extension.
Install eslint-config-prettier
npm install -D eslint-config-prettier
Add prettier to the “extends” property in the .eslintrc.js file as the last item.
{
"extends": [
"some-other-config-you-use",
"prettier" // <- keep prettier as last item in extends property.
]
}
eslint-plugin-security: This plugin will help identify potential security issues with the code. Check the repo for more details.
Install the plugin.
npm install -D eslint-plugin-security
And update the plugins and extends properties in .eslintrc.js file.
"plugins": [
"security"
],
"extends": [
"plugin:security/recommended"
]
Make sure you add this security plugin leaving our prettier plugin as last.
That's it. Let's run the lint script again.
Cool. Now we have 47 genuine errors.
Ignoring files: serviceWorkerRegistration.ts and service-worker.ts are given to us by the CRA template and I'm not going to lint and manage them.
Paste the below comment on the top of the above two service worker scripts.
/* eslint-disable */
If you don’t want to rely on a comment to do this stuff, you can create .eslintignore in root and write down the paths of the files you don't want to lint.
import/no-unresolved: This error shows up if Eslint could not resolve an import in the file system. Even though we have @typescript-eslint/parser
as our parser, ts and tsx are not getting resolved.
extends: [
// other extensions
‘plugin:import/typescript’
]
import/extensions: For some reason, the Airbnb config does not allow importing typescript files. Same as the above but a different rule to be solved. So, add the below rule.
'import/extensions': [
'error',
'ignorePackages',
{ts: 'never',tsx: 'never'}
],
no-use-before-define: This rule is complaining the below error which does not make sense.
‘React’ was used before it was defined. eslint no-use-before-define
So, I'm going to turn the JS version of it and use the typescript version.
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'error',
react/react-in-jsx-scope: This rule says, ‘React’ must be in scope when using JSX. The new jsx
transform, does not require us to import React in every component which saves us some time and space. So, I’m going to turn that off.
'react/react-in-jsx-scope': 'off',
Let eslint recognize jest: Eslint is not recognizing the global jest exports like the test, and expect.
Add jest: true in the env property of .eslintrc.js file.
env: {
browser: true,
es2021: true,
jest: true,
},
react/jsx-filename-extension: This rule complains “JSX not allowed in files with extension ‘.tsx’”.
So, let’s make eslint allow jsx inside .tsx files by adding the below in the rules of .eslintrc.js file.
'react/jsx-filename-extension': [
'error',
{ extensions: ['.tsx'] },
],
import/no-extraneous-dependencies: This rule complains as we import stuff from @storybook. As @storybook is installed as a dev dependency. Let's add a rule that allows us to import stuff from dev dependencies too.
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: true, },
],
no-unused-vars: In the below screenshot, “key” is not a variable but a type parameter. And the rule no-unused-vars mistakes for an unused variable.
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error'],
So, we have done this twice now. Turning off a JS version of the rule to turn on the typescript version (no-use-before-define and no-unused-vars). I think there should be a simpler way of doing this. I’ll update this part.
That leaves us with three errors.
- Prop spreading is forbidden react/jsx-props-no-spreading
- Unexpected empty object pattern no-empty-pattern
- Unexpected block statement surrounding arrow body; move the returned value immediately after the `=>` arrow-body-style
As the errors are self-explanatory I’ll let you fix them.
This is the configuration file at its final.
Typescript
Add this to the “scripts” in package.json.
"type:check": "tsc"
That is it. “tsc” in the command that checks the code for type issues. By default, the command also compiles our .ts and .tsx files to .js and .jsx files. But we have “noEmit”: true in compiler options in tsConfig.js to stop emitting js files.
npm run type:check
That’s it. When the type checker is silent, we have to understand that we did not do anything wrong.
I’ll let you introduce some type issues in the code and check if the npm run type:check catches it.
noImplicitAny
Yes. We abolish any
in this project. Any data that passes in this application will have a type.
“noImplicitAny”: true,
baseUrl
We enable non-relative imports with baseUrl. Add baseUrl in the compilerOptions
in tsconfig.json.
// tsconfig.json"baseUrl": "."
This will let us import from the location of the tsconfig.json file (root). So we can import like import Button from ‘src/components/atoms/Button
’
Validation: Using all three
Install run-p to run the above three scripts in parallel.
npm install -D npm-run-all
The package can be used as npm-run-all, run-s, or run-p. We will use run-p for parallel execution.
"validate": "run-p lint type:check format:check"
Now, The below script will run all three linting, formatting, and type checking for us.
npm run validate
Husky
Husky lets us access git hooks.
What are git hooks? Git hooks are scripts that run automatically every time a particular event occurs in a Git repository.
We will be using the pre-commit hook to run our validate script so that the developers can not even commit any code without passing the validation. We can even run our tests or any other npm scripts on the same hook.
Lint staged
Running our validation script in the pre-commit git hook is fine. But should we lint, format, type-check an entire project if the developer only changed a couple of files? Lint staged solves that problem.
npx mrm@2 lint-staged
The above command will set up lint-staged using husky’s pre-commit hook.
mrm
is a command-line tool to help us create/maintain configuration files.
You can find the lint-staged property added to the package.json file with the following commands.
Create a file lint-staged.config.js
as paste the below.
module.exports = {
'*.{ts,tsx}': (filenames) => [
'npm run format:fix',
'npm run validate',
],
}
lint-staged can be configured in package.json
, but I would like to keep the lint-staged configuration in its own file. I also use an array of scripts under .ts and .tsx files.
Another great thing about lint-staged is that it automatically does the git add on the modified files. So, if we are doing prettier — write or eslint — fix, we don't have to stage the changes manually.
What do we do to the staged files?
- We do format:fix.
- We run our validate script only on the staged files.
Let's commit the code.
git add .
git commit -m 'testing lint staged 1'
Now, the lint-staged kicks in and runs.
And if successful, we will see this
Notes
- I only validate the .ts and .tsx extensions. If needed, we will create another property for other extensions with the needed list of scripts.
- If the validate script fails, It may not be easy to find what failed exactly. If you feel the same, you can add the format:check, lint, type:check separately as shown in the example below. (discard the git add though)
For clarity: If your lint-staged has a clean array of commands as shown below, we can easily find what failed. (discard git add)
So, that is it.
We have a robust setup of formatting, linting, type checking implemented along with husky and lint-staged to protect our codebase from any bad commits entering.
What kind of confidence the above setup provides? A scenario.
Imagine this.
The backend has provided us (frontend) with the type of response we would receive in an API call. We build our whole front end based on that type.
Now, the inevitable thing happens. The backend had to immediately update an API response to save a terrible security flaw or something.
Now, what will happen to our front end? Our compiled javascript code is going to break miserably. If we are using react and error boundary, we can fail gracefully with the “oh no something went wrong” page. But still, the front end is going to break.
The backend reasons why they had to break the contract that is built on trust. and so on. Now, the front end has a breaking web application in production! And they have to fix it as soon as possible! The whole company including the owner is looking at them.
As our application is set up with typescript we can update the response type with the new one and simply run,
npx tsx
That will list all the lines of code we need to update to comply with the updated response type. Go to each type-error and fix them. Our linter will also be supervising as we type. When the typescript and our linter are happy we can be confident that the change will work.
And, if the project also has well-written tests that run critical user flows, we can confidently push with the obnoxiously huge number of updated files!
Imagine the above scenario without typescript. It will become a nightmare. We will miss one place or another and then later we find the problem the more complex it will be.
Commitlint
Install commitlint with the conventional config and cli.
npm install --save-dev @commitlint/{config-conventional,cli}
Create the config file.
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
Add a commit-msg hook in husky.
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
That’s it. Now commit your code with a commit message that does not support the conventional config.
How cool is that? But if you change the commit message to support the conventional config, like git commit -m ‘fix: added husky hook’
, the commit will pass.
Read here about the benefits of following conventional commits.
Protect the main branch
Let's protect the main branch as if no one can directly push commits to it.
Go to settings -> Branches(Code and automation) -> Add rule
.
Enter Branch name pattern as main
and configure the protection settings as needed.
Click create
button at the bottom.
Now, if we try to push commits to the main branch, you will see the below error!
The main branch is protected from direct code pushes. Now any changes need to enter through pull requests.
Adding collaborators
Let's add a collaborator who can verify the pull requests.
Go to Settings -> Collaborators -> Add people
. I’m going to add the other me as a collaborator.
If the person accepts the invitation, voila you have got a collaborator!
Github actions
We have employed many defense mechanisms above. Github actions pipeline sits closest to our production code.
Go to the Actions tab and search node. Click configure on Node.js.
Paste the below code and click Start commit.
As the main branch is protected you will be asked to create a new branch and then raise a pull request.
As we configured Require approvals to 1 earlier, Someone needs to review the pull request. The person who raised the pull request can not review that.
The below screenshot is taken from the collaborator’s screen.
Click Add your review and approve changes with the ritual LGTM comment.
That’s it.
Now any PR needs to pass our validate script which has linting, formatting, and type-checking in parallel.
Feeling safe?
Visual regression testing
Unexpected UI breaks are one of the leading causes of the OMG moments for the developers after deploying changes.
Chromatic is a visual regression tool that works in tandem with the storybook. For each PR, it would be very useful if we have means to look at which UI’s changed.
Let's implement chromatic into our project. Go to chromatic.com
and create a project. Chromatic has one of the smoothest developers' onboarding experiences.
I’ll let you experience that yourself.
Each project will have a token. The below command will build the storybook and publishes it to chromatic.
npx chromatic --project-token=yourtokenhere
Add the below chromatic UI verification step in our CI after the validation step.
- name: Publish to Chromatic
if: ${{ github.event_name == 'pull_request' }}
uses: chromaui/action@v1
# Options required to the GitHub Chromatic Action
with:
token: ${{ secrets.GITHUB_TOKEN }}
# 👇 Chromatic token, refer to the manage page to obtain it.
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
Also, create a repository secret (Settings -> Secrets -> New repository secret)
called CHROMATIC_PROJECT_TOKEN
with your chromatic token.
Create a script in package.json.
"chromatic": "chromatic --exit-zero-on-changes",
Now, on each PR, we get a chance to verify which UI components changed visually. So that we can catch any unexpected changes reaching the production code.
Thanks for reading. 🫂🫂