Husky + Lint-Staged on a React TypeScript Project

Automate validation before submitting your code

André Borba Netto Assis
JavaScript in Plain English

--

Introduction

Even if you set up tools that guarantees some code quality, it is possible that you can forget to execute them before pushing your code.

To automate and solve this problem, Husky + Lint-Staged packages help you prevent submitting code that does not follow some predefined rules (i.e: unit tests validation, code convention validation, code formatting, etc).

In this article I will teach you about Husky, Lint-Staged and how use those packages on a React TypeScript project.

Prerequisites

Node.js:You need Node version >= 10 installed. So, if you don’t have it, please go to NodeJS website, download and install it on your local machine. (https://nodejs.org/en/)

Git: You need Git version >= 2.13.0 installed.So, if you don’t have it, please go to Git website, download and install it on your local machine. (https://git-scm.com/downloads)

Step 1. Start a React TypeScript Project with Git

The following command will create a project inside a folder ‘my-app’.
On terminal, run:

npx create-react-app my-app --template typescript

Inside the project folder, initiate git:

On terminal, run:

git init

Note: For this tutorial purpose you don’t need to point a repository, but if you want you can do it with the follow command:

git remote add origin <your_existing_repository_cloning_url>

Step 2. Setting up ‘husky’ package

Installation:

We will use Husky version 4. There is already a newer version, but is exclusive for Open Source projects or if you are a sponsor of Husky at GitHub Sponsors or Open Collective.

On terminal, run:

npm install husky@4 --save-dev

Husky configuration file

Create a ‘.huskyrc.json’ file and let it empty for now (we will configure it ahead).

On this file we can define any Git Hooks.

What is a Git Hook? Git Hooks are actions that may be executed if a given Git event occurs. i.e: when the developer commit or push some code, run all the unit tests and just finish the commit/push if all tests passed.

Setting up to commit only if all Unit Tests passed

To trigger some action before committing, we will use the Git Hook called “pre-commit”.

Let’s now create an action to execute our ‘test’ command script (declared in our ‘package.json’ file). The main goal of setting this action is to block the user from committing the code If some test fails.

command scripts on “package.json” file

So, inside .huskyrc.json file:

{
"hooks": {
"pre-commit": "npm run test -- --watchAll=false"
}
}

Note: As you see, Husky Configuration file can use any script on “package.json” by just calling it using npm run.

But wait… what is -- --watchAll=false?

The first double dash “ --” tells our npm command that we will pass arguments to it (See more infomation here: what’s mean of npm scripts two dashes?).

The command option “--watchAll=false” is because when executing ‘test’ script, React will start a watch-mode menu, so you can select if you want to run all files, specific ones, just the modified ones, etc. In our case, we don’t want it to ask anything, but just run all the tests. So we turn off this watch-mode by adding ‘ --watchAll=false’ to it.

Now, let’s try it out with our brand new Husky Configuration!

Before committing our code, let’s just test our command:

npm run test -- --watchAll=false
unit test running successfully

The command works! Great! So, because of our new Husky configuration if we commit the test should run before committing, right? Let’s check!

git commit -am "I hope the test run before this commit!"
result: test ran before committing

Cool! It works!

Now, Let’s see what happens if the test fails?

First, undo the commit with the follow Git code:

git reset HEAD^ 

(See more information here: Can I delete a git commit but keep the changes?)

Now we need to force the unit test on our project to fail.

If we analyze the unit test file App.test.tsx. it is expecting that App.tsx file renders an element with the “Learn React” text.

To force it to fail, let’s modify our App.tsx file to render another text.

Modifying ‘App.tsx’ file

Let’s run it again and see if the test will fail:

npm run test -- --watchAll=false
result: test fails

Yay! Now let’s commit and see what happens:

git commit -am "I hope this commit will be blocked"
husky response: pre-commit hook failed

See? The commit was blocked because the test fails! Cool, right?

Setting up to commit only if the ESLint validation rules passed

Let’s set up a new action based on the default ESLint configuration provided by our ReactJS project.

First, let’s run the ESLint over all the project and see what happens:

npx eslint .

By running this command you will see that nothing happens, so it means that the whole project’s code is already following the ESLint code rules provided by React.

Note: By default ESLint will throw an error only if the rule violated is set up as an “error”. If you also want throws for “warn” rules violation, you could do it by adding “--max-warnings=0” command option.

Example:

eslint . --max-warnings=0

Now, let’s change our .huskyrc.json file to use the ESLint command:

{
"hooks": {
"pre-commit": "eslint ."
}
}

The configuration is done!

Just for teaching purposes (so we can see the commit being blocked by the ESLint code rules) let’s force ESLint to throw an error on some rule and insert a code that violates that rule.

The ESLint configuration is set up on package.json file.
On the json eslintConfig section, add the code below:

"rules": {
"@typescript-eslint/no-unused-vars": "error"
}

Note: This rule is related to non used variables, so ESLint will throw an error if some variable is declared but not used in code.

The result in our ‘package.json’ is:

...
"eslintConfig":{
"extends": [
"react-app",
"react-app/jest"
],
"rules": {
"@typescript-eslint/no-unused-vars": "error"
}
},
...

Now, to force the ESLint error, let’s declare a variable “unusedVar” and not use it anywhere .

On App.tsx file:

...
const unusedVar = "";
function App() {
return (
...

So, let’s run our eslint command again and see if throws an error:

npx eslint .
eslint point out a code violation

Let’s see what happens when we try to commit the code:

git commit -am "I hope this commit will be blocked"
husky response: pre-commit hook failed

See? The commit was blocked because an ESLint rule is not being applied! Cool!

ESLint auto-fix and Husky problem

The last solution is kind of incomplete. But why?

ESLint, as many other packages, can auto-fix code that doesn’t follow some basic rules (for example, adding or removing semi-colon ‘;’ at the end of lines). So, imagine the situation where all of the bad code that blocks our commit could be fixed automatically by the ESLint with its auto-fix ‘ --fix’ command option.

Hmm, so we just need to add it to our Husky Configuration file and for these situations the commit will be sent with all the code fixed, right?

Unfortunately, no.

Husky cannot execute all of these actions:
(at least there is no easy way to do it)

  • Fix the code
  • Check if solved all the problems
  • Add the fixed code to staged
  • Commit the code.

Luckily we have an npm package called ‘lint-staged’ that can do it for us! Let’s see how it works.

Step 3. Setting up ‘lint-staged’ package

Lint Staged run actions on the files that are staged by Git (in other words. files that are ready to be committed).

Installation

On terminal, run:

npm install lint-staged --save-dev

Lint-Staged configuration file

Create a .lintstagedrc.json file and let it empty for now (we will configure it ahead).

This file will contain the script that will be executed only over the staged files. If the script does not throw any error, all the changes made by the action are kept to the staged files.

Using the ESLint auto-fix and Lint-Staged: problem solved!

Now that Lint-Staged is installed, let’s solve the Husky limitation and configure our ESLint to run its auto-fix command on the staged files.
On ‘lintstagedrc.json, add the follow:

{
"src/**/*.{js,ts,jsx,tsx}": [
"eslint --fix"
]
}

Note: You could also use any script on package.json by just calling it using npm run.

Let me explain this file content:

  • src/**/*.{js,ts,jsx,tsx}” tells that lint-staged will run some action just over the files inside ‘src/’ directory and only for those having ‘.js’, ’.ts’, ’.jsx’ or ’.tsx’ extension file.
  • The action is the “eslint --fix” that executes ESLint rules check and also does the auto-fix on the code.

Last, Husky needs to call the lint-staged as an action now, to activate our .lintstagedrc.json file configuration.

On .huskyrc.json file:

{
"hooks": {
"pre-commit": "lint-staged"
}
}

Understanding the scenario:

  1. When we commit, Husky will call Lint-Staged.
  2. Lint-Staged looks just for the files that are staged, and select those that are inside ‘src/’ directory and has one of the ‘.js’, ’.jsx’, ’.ts’, ’.tsx’ extensions defined.
  3. Lint-Staged then will call the action “eslint --fix” over the selected files, checking code rules and applying the auto-fix when possible.
  4. If eslint succeeds, the commit will complete, otherwise it will be blocked.

In our case, we expect to be blocked because there is an unused variable on our code and ESLint cannot auto-fix this code violation by itself.

Let’s see if our new Husky + Lint-Staged configuration works:

git commit -am "I hope this commit will be blocked"
Husky + Lint-Staged execution

See? The commit was blocked because Husky called Lint-Staged command that called the ESLint rule check, and as we expected the rule “no-unused-vars” is violated by our unused variable at ‘App.tsx’ file!

Prettier auto-fix and Lint-Staged: formatting JSON files

If you want to auto-fix JSON files, you can set Lint-Staged Configuration file to run Prettier auto-fix formatting over the JSON files.

{
"src/**/*.{js,ts,jsx,tsx}": [
"eslint --fix"
],
"*.json": [
"prettier --write"
]
}

Useful Tips

We have finished! But let me give you some very useful tips!

Organize all of your scripts over ‘package.json’ file

We already know that “.huskyrc.json” and “.lintstagedrc.json” also accept actions using the scripts defined on “package.json” file.

To better organize your project, declare all the scripts on “package.json” file and use it on “.huskyrc.json” and “.lintstagedrc.json”.

Example:

On ‘package.json’:

  • Define “lint” script
  • Define “test:noWatch” script
...
"scripts": {
...
"test": "react-scripts test",
"test:noWatch":"npm run test -- --watchAll=false",
"lint": “eslint --fix"
...
},
...

On ‘.huskyrc.json’’:

  • Call “test:noWatch” script from “package.json
{
"hooks": {
"pre-commit": "npm run test:noWatch && lint-staged"
}
}

On ‘.lintstagedrc.json’:

  • Call “lint” script from “package.json
{
"src/**/*.{js,ts,jsx,tsx}": [
"npm run lint"
]
}

Best Husky configuration

Setup the Git Hook “pre-commit” with faster checks (like linting), because you probably will commit many times before push.

Setup the Git Hook “pre-push” with slower checks (like checking unit tests), because you probably will push less times than committing.

On ‘.huskyrc.json’’:

{
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "npm run test:noWatch"
}
}

Configure these validations also on your CI pipeline

Creating these policies does not block the user from committing a code that is not according to the expected rules, because even the Git Hooks “pre-commit” and “pre-push” can be bypassed with the “ --no-verify” command option.

To guarantee that no bad code will achieve the repository, I strongly advise you to also set up the validations on the CI (Continuous Integration) pipeline process. But this subject is for another time.

Thanks!

Hope you enjoy!
Any feedback would be greatly appreciated!

References:

--

--