How We Added ESLint and Prettier to a Legacy React Codebase Without Freezing the Team
There’s a familiar problem with adding a linter to a codebase that doesn’t have one. The moment you install it and add a config, every existing violation in the codebase becomes a build error. If your team is shipping product, you now have two bad options: ask for a freeze week to clean everything up before you can land the install, or land the install with the rules turned down to nothing and slowly tighten over time.
We took a third path: install the tooling, deliberately disable it in CI as temporary scaffolding, clean up incrementally on a side branch, then remove the scaffolding in a separate PR. The cleanup ran in parallel with our RBAC overhaul and our multi-pod migration. Nobody had to freeze.
This post is about that pattern, in detail. Three PRs. One prod deploy. One deleted plan doc. The disable flag is gone.
The Starting State
Our React codebase (fe-react-smo) had no ESLint config, no Prettier, and no react-hooks plugin. The only ESLint that had ever run on the code was the plugin Create React App ships with by default — and CRA’s plugin only enforces a tiny set of rules out of the box. There was no formatter at all. Some files were 2-space indented, some were 4-space. JSX prop wrapping was a coin flip. useEffect hooks weren’t being checked for missing dependencies because we had never installed the plugin that does it.
The codebase wasn’t chaotic by intent. It just slowly diverged, the way every codebase without enforced style does. Two engineers prefer 2-space indents, one prefers 4. Some files use single quotes, some use double. There’s no malice in any of this — it’s just what happens when nothing is enforcing a baseline.
The cost isn’t theoretical. PR review burns minutes on “can you wrap this line at 100 chars” comments instead of substance. react-hooks/exhaustive-deps issues that would have caught real bugs slip through silently. New engineers join, see no enforcement, and write code in whatever style they prefer. Every commit makes the eventual cleanup bigger.
The Core Problem with Adding a Linter Late
Here’s the catch nobody warns you about. Once you add a .eslintrc to a Create React App project, CRA’s built-in ESLint plugin picks it up automatically. Your npm start and npm run build commands will now fail on every existing violation. So will every PR check. So will every CloudBuild deploy.
That’s the right behavior, eventually. But on day one of the rollout, “every build is now red” is not a workable state. You can’t ship features. You can’t deploy hotfixes. The team is effectively frozen until the cleanup is done.
The naive responses to this are all bad:
Freeze the team. Stops product. The team won’t love you.
Set every rule to
warnoroffinitially, tighten later. Means the linter isn’t actually enforcing anything for weeks or months. The “tighten later” step almost never happens.Land cleanup all in one giant PR. Unreviewable. Conflicts with everything in flight. High risk of regressions.
We went with a different approach: install the tooling normally with the rules we actually wanted, but temporarily set a CI environment variable to disable CRA’s built-in plugin while we cleaned up. The flag is a deliberate, documented scaffolding step. It comes out as soon as the cleanup is done.
The Four-Pass Pattern
Each pass shipped as its own reviewable unit on a long-lived branch (sanket/lint), rebased frequently against main to avoid merge-time pain.
Pass 1 → Install ESLint + Prettier with the rules we want long-term.
Add DISABLE_ESLINT_PLUGIN=true to CI as temporary scaffolding.
Add LINT_CLEANUP_PLAN.md to document the rollout.
Pass 2 → Auto-fix sweep: eslint --fix and prettier --write across the codebase.
One PR, no semantic changes.
Pass 3 → Manual triage of remaining violations.
The work that auto-fix can't do.
Pass 4 → Remove DISABLE_ESLINT_PLUGIN from CI.
Delete LINT_CLEANUP_PLAN.md.
Enforcement is now permanent and the scaffolding is gone.
Pass 1: Install + Scaffold
The single biggest decision in this kind of rollout is rule selection. Every rule you turn on is debt you commit to clearing. Pick wrong and you’ll either spend weeks on cleanup, or you’ll quietly disable rules until the config is meaningless.
Our base ESLint config:
// .eslintrc.js
module.exports = {
extends: [
'react-app',
'react-app/jest',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier', // turn off ESLint rules that conflict with Prettier
],
rules: {
// Things we want ENFORCED (errors)
'react-hooks/rules-of-hooks': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-shadow': 'error',
'eqeqeq': ['error', 'always'],
// Things we want VISIBLE but not blocking (warnings)
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: ['warn', 'error'] }],
// Things we want OFF entirely
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
};
Two notes on this config worth lingering on.
First, eslint-config-prettier goes last in extends — it exists specifically to disable ESLint’s stylistic rules that would conflict with Prettier, and order matters. Get this wrong and you’ll spend an afternoon wondering why ESLint and Prettier are fighting each other on the same files.
Second, error vs warn is the most important call you’ll make. Errors block CI. Warnings don’t. If you set a rule to error that has thousands of existing violations, you’re either committing to cleaning all of them up before the scaffolding can come down, or you’re committing to disabling the rule later. So no-explicit-any and react-hooks/exhaustive-deps started as warnings. There were too many subtle hook-deps cases and too many existing anys to clear in this rollout. We made them visible — every PR shows the warning count — without making them blocking. The warnings are a backlog we can chip away at later. Errors are a freeze.
Prettier was deliberately boring:
// .prettierrc
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"arrowParens": "always"
}
The goal of Prettier config is not to express your aesthetic preferences. It’s to make every file in the codebase look like every other file. Bikeshed for an hour, then never touch it again.
Now the scaffolding. With the config in place, npm run build would have failed on every existing violation. So in the same PR, we set DISABLE_ESLINT_PLUGIN=true in two places:
# pr-checks/Dockerfile
ENV DISABLE_ESLINT_PLUGIN=true
# cloudbuild.yaml
env:
- 'DISABLE_ESLINT_PLUGIN=true'
This is a Create React App-specific environment variable. It tells CRA to skip its built-in ESLint plugin during npm start and npm run build. With the flag set, CI keeps passing even though the codebase has thousands of new violations under the new config.
The crucial mental shift: this flag is scaffolding, not a workaround. It exists for one reason — to keep the team shipping while the cleanup happens — and it has a clear exit criterion: the day the codebase passes lint with zero errors, the flag comes out.
In the same PR, we added a LINT_CLEANUP_PLAN.md at the repo root. Its contents weren’t elaborate — a short list of the passes ahead, the rules in play, and the exit criteria. The doc had two purposes: it announced to the team what was happening, and it served as a personal todo list for the cleanup work.
Locally, npm run lint worked normally and surfaced all the errors. Anyone who wanted to see the truth could see the truth. The scaffolding only suppressed it in CI, where it would have blocked merges.
Pass 2: The Auto-Fix Sweep
Pass 2 (PR #300) is the most satisfying part of the project, and also the part most likely to mislead you about how much work is left.
# Run autofix everywhere
npx eslint . --fix
npx prettier --write "src/**/*.{ts,tsx,js,jsx,json,css,md}"
The diff this produces will look terrifying. Quote characters change. Semicolons appear. Indentation snaps to grid. Unused imports vanish. Trailing commas appear. JSX prop wrapping stabilizes. Files that had never been touched by a formatter suddenly look like they belong to the same codebase. The total error count drops by something like 70-85% in one commit.
A few rules for this pass:
Commit it as a single PR with a descriptive title. The commit message says “ESLint + Prettier auto-fix sweep — no semantic changes” so reviewers know not to look for behavior changes.
Don’t bundle any manual changes into this PR. If you fix one bug while you’re in there, the PR stops being mechanical and reviewers have to actually read every file. Keep it pure auto-fix.
Run the test suite before merging. Auto-fix is mechanical, but it’s not perfect. Removing what looks like an unused import that turns out to be a side-effect import will break things. The test suite is your safety net.
Rebase, don’t merge, when integrating other work. A long-lived auto-fix branch will conflict with everything. Rebasing keeps the diff history clean and minimizes merge-time confusion.
After the sweep, the error count was much smaller. But “smaller” is not “zero,” and the remaining errors were the work.
Pass 3: Manual Triage (Where the Real Work Lives)
When you run auto-fix against a codebase that has never seen a linter and the error count drops 80%, the lingering 20% feels like an afterthought. It is not. The remaining errors are where you slow down and read code, and where the rollout actually earns its keep. This work shipped as PR #301.
Roughly, what survives auto-fix in a React codebase falls into four buckets.
1. react-hooks/exhaustive-deps cases.
These are the most dangerous warnings to “fix” mechanically. Adding a dependency to a useEffect because the linter said so is one of the canonical ways to create infinite re-render loops. Every one of these has to be read.
The judgement call looks like this:
// The lint warning says: missing dependency 'fetchData'
useEffect(() => {
fetchData(userId);
}, [userId]);
If fetchData is stable (defined outside the component, or wrapped in useCallback), adding it as a dependency is fine and the lint warning is correct. If fetchData is redefined every render, adding it will cause the effect to fire on every render forever. The fix in that case isn’t to silence the lint — it’s to wrap fetchData in useCallback upstream. Every one of these is a one-to-five-minute investigation.
2. Unused variables that look unused but aren’t.
Destructured props passed through ...rest, function arguments required by an interface, type-only imports that TypeScript needs but ESLint can’t see. The argsIgnorePattern: '^_' config helps — you can rename intentionally-unused args to _arg — but each of these is a small judgement call.
3. no-shadow and naming collisions.
These are the bucket where the rollout occasionally finds real bugs. A variable was reusing the name of a parameter. The inner one was masking the outer one. Nobody noticed because the code happened to work. Fixing these properly — by renaming, not by disabling — is exactly the kind of cleanup that makes the codebase better.
4. Things that should be disabled, not fixed.
Some patterns genuinely need a rule disabled — generated files, a particular HOC pattern, a test fixture. The trick is to disable narrowly:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- legacy API contract
const data: any = legacyClient.fetch();
Always with a comment. Never file-wide unless you have a very good reason. Never config-wide unless you’ve decided the rule is wrong. The temptation to add // eslint-disable to make the count go down is the temptation to undo your own work. If you find yourself disabling more than a handful of files, your rule set is wrong, not the code.
The honest version of “we zeroed out all ESLint issues” is: auto-fix did the bulk, and then someone — me — sat with the rest and read each one. There’s no shortcut. This was the slowest pass and the highest-judgement one.
Pass 4: Remove the Scaffolding
Pass 4 (PR #302) is where the project actually ends. Once the codebase passed lint with zero errors locally, two things came out:
# pr-checks/Dockerfile
- ENV DISABLE_ESLINT_PLUGIN=true
+ # ESLint enforced via .eslintrc.js
# cloudbuild.yaml
env:
- - 'DISABLE_ESLINT_PLUGIN=true'
+ # ESLint enforced via .eslintrc.js
That tiny diff is the entire point of the project. Everything before it was a prerequisite. Without the flip, the scaffolding stays up, the cleanup decays in weeks, and within a quarter you’re back where you started.
Worth being precise about what’s now enforced. With .eslintrc.js checked in and DISABLE_ESLINT_PLUGIN removed, CRA’s built-in ESLint plugin runs during npm start and npm run build, picks up our config, and fails the build on any rule we marked as error. PR checks fail the same way. There’s no separate “lint step” we needed to add to CI — re-enabling CRA’s built-in plugin is the entire enforcement mechanism. A separate prettier --check . step in CI handles formatter enforcement.
The same PR removed LINT_CLEANUP_PLAN.md. The doc had served its purpose: it announced the work, tracked the steps, and acted as a public commitment. Once the work was done, the doc was just noise. Removing it in the same PR as the CI flip is a small but useful signal that the team has actually closed the loop.
We deployed on April 28. Linting and formatting have been enforced ever since.
Why This Pattern Works
The pattern that makes this work is treating the disable flag as a deliberate, documented, time-bound piece of scaffolding rather than as a workaround.
Workarounds are how disable flags become permanent. Someone hits a problem, sets a flag to make it go away, and the flag stays because nobody owns removing it. That’s the mistake every team eventually makes. The flag was supposed to be temporary. Six months later it’s load-bearing.
Scaffolding is different. Scaffolding has an exit criterion baked in. Ours was: “the disable flag comes out the day the codebase passes lint with zero errors.” The plan doc made the exit criterion public. The four-pass structure made the path to it concrete. Each pass was a reviewable PR with its own purpose. There was never a moment where “what’s left to do” was unclear.
Two specific things about this approach worth calling out.
The scaffolding only affects CI, not local development. Locally, npm run lint worked normally and showed every violation. Anyone could see the actual state of the codebase at any time. The flag suppressed enforcement only at the gate — where it would have blocked the team — not at the desk, where it would have hidden the work.
The cleanup PRs were branched off main, not off the scaffolding PR. This sounds obvious but it matters. If the cleanup PRs depended on the scaffolding PR, removing the scaffolding later would have been entangled with reverting the cleanup. Keeping them independent meant Pass 4 could be a clean two-line diff in two files, easy to review and easy to roll back if something went wrong.
The Results
Before this rollout:
No project-level ESLint config. No Prettier. No
react-hooksplugin.No formatter enforcement of any kind. Inconsistent style across the codebase.
react-hooks/exhaustive-depsviolations going unnoticed and unsurfaced.
After:
ESLint, Prettier, and the
react-hooksplugin all installed and configured.Both linter and formatter enforced in CI on every PR.
A consistent, mechanically-enforced code style across the entire codebase.
The disable flag added during rollout, then cleanly removed.
Plan doc deleted.
New violations get caught in PR review automatically — reviewers can spend their attention on substance.
All of this shipped in three PRs (#300 for the auto-fix sweep, #301 for manual triage, #302 for the CI flip and plan-doc deletion) with no freeze week, no halted features, and no team-wide style debates.
Lessons Learned
Treat the disable flag as scaffolding, not a workaround. Add it deliberately, document it publicly, give it an exit criterion. Workarounds become permanent. Scaffolding comes down.
Auto-fix gets you 70-80% of the way. Plan for the remaining 20% to take most of the time. Especially react-hooks/exhaustive-deps cases. These are where bugs hide and where mechanical “fixes” introduce new ones. Budget accordingly.
Warn, don’t error, for rules you can’t clear today. no-explicit-any, react-hooks/exhaustive-deps, no-console, anything with hundreds of existing violations. Make it a warning, get the rest of the system enforced, come back to the warnings as a long-tail cleanup later. Errors that can’t be cleared get disabled. Warnings just sit there, visible, doing no harm.
Ship the CI flip as its own PR. It’s the most important PR of the project, and it should be small enough that anyone can review it in 30 seconds. Bundling it with cleanup is how the flip gets accidentally reverted on a rebase later.
Delete the plan doc when the work is done. A LINT_CLEANUP_PLAN.md that lingers after the project is finished is a sign of an unfinished project, even when the project is finished. When the work is done, the doc goes.
Enforcement is the actual work. Adding ESLint and Prettier to package.json is the easy part. Anyone can do that. The hard part — and the part that fails most often — is making the codebase pass and the CI enforce it without a heroic week-long sprint. Sequencing it as a side current of normal development, with explicit scaffolding, is what makes that possible.
When to Use This Approach (and When Not To)
This staged rollout works well when:
The codebase is somewhere between 10k and 200k lines. Smaller, and you can probably just freeze for a day. Larger, and you’ll need more aggressive scoping (lint per directory, ratchet up over time).
You have one person who can own the cleanup branch end-to-end. Two people thrashing on the same long-lived branch tends not to work.
The team is shipping features in parallel — you don’t have a quiet week, but you also aren’t in the middle of an emergency.
Auto-fixable rules dominate the violation set. If your codebase is 80% custom rules with no auto-fix, scope down before you start.
Consider a different approach when:
You have an active migration to a new framework or major version. Do that first; lint after.
The codebase has multiple authoritative styles already. Pick one before turning Prettier loose, or you’ll re-litigate the formatting war in PR comments.
You can’t get sign-off to flip enforcement in CI. Without the flip, the scaffolding stays up forever and the project never actually finishes.
Wrapping Up
The technical part of this kind of work is not the hard part. ESLint, Prettier, auto-fix, the configs — all of that is well-trodden ground. The hard part is sequencing it so the team keeps shipping, the cleanup actually finishes, and the enforcement actually stays on once you walk away.
The trick that made it work for us was treating the disable flag as deliberate scaffolding with an exit criterion. Install the tooling with the rules you actually want. Disable enforcement in CI temporarily so the team isn’t blocked. Document the plan publicly so everyone knows it’s temporary. Clean up incrementally — auto-fix first, then manual triage. Then remove the flag, delete the plan doc, and let the linter do its job.
Three PRs. One controlled rollout. No freeze week.
If you’re staring at a React codebase that needs a linter and you’re worried about blocking the team, this is the playbook. Install, scaffold, sweep, triage, flip, delete. In that order.
Sanket Dofe is a Software Engineer at Wynisco. He spends a non-trivial fraction of his time installing flags that say “DISABLE” — and then taking them out again. Opinions are his own.|
Written by
Sanket Dofe
