Messy code on your main branch is a classic headache that usually happens when
you only run linters in CI. Inconsistent styles or silly bugs eventually slip
into main, and they end up becoming a burden for the whole team.
The fix is using pre-commit hooks, but people often get the tooling mixed up. I
see many people comparing Husky to lint-staged as if they're competitors, when
they actually do very different things. It's like comparing a key to a lock;
they work together rather than replacing one another.
Defining the roles
Husky is your Git hook manager. Its job is to make sure a specific command runs the moment you commit. In the Node.js ecosystem, Husky is basically the standard because it’s easy to set up and well-documented.
On the other hand, the job of running commands only on the files you've actually
staged belongs to lint-staged or nano-staged.
A major mistake I often see in teams is running the linter against the entire
project every single time someone commits. In a large monorepo, running
eslint . on every commit is a disaster. It’s incredibly slow, and eventually,
the team will just get tired of waiting and stop using the hooks altogether.
Choosing the right runner
If you want to run a linter only on selected files, lint-staged is usually the
go-to. It’s smart enough to map out which files are currently staged. But
there’s a catch: your linter script must be able to accept a list of files as
arguments. If your script is hardcoded to lint the entire folder regardless,
using a staged runner is a waste of time.
Then there's nano-staged.
nano-staged is a lighter, faster alternative. It’s worth a look if the
overhead of lint-staged starts to feel like too much, especially if you only
need very simple configurations. But keep in mind: don't swap tools just for the
sake of it. If your linting feels slow, the problem is likely a bloated ESLint
config, not the runner itself. Fix your rules before you replace your tools.
What do we actually need?
We really only have three goals: format the files we're working on, run the
linter with the same rules used in CI, and make sure the process is fast enough
that the team doesn't start using --no-verify just to skip the wait.
Not everything needs to be checked at every commit. We don't need to run a full
monorepo typecheck or a massive test suite at this stage. Heavy lifting like
that belongs in CI or perhaps at the pre-push stage.
Using pre-commit hooks can definitely disrupt your flow if the configuration is a mess. But when it's set up right, it’s a small investment that effectively keeps code quality in check without having to wait for a CI report.