Code Quality with ESLint and Prettier
Understand the distinct roles of ESLint (linting) and Prettier (formatting), configure both for a TypeScript React project, wire them into your editor, and enforce them automatically via pre-commit hooks and CI checks.
As a codebase grows past one person, informal style agreements stop working — someone always forgets a semicolon convention, indents with tabs instead of spaces, or writes a subtly buggy comparison. ESLint and Prettier automate the two different jobs that keep a shared codebase consistent and correct: ESLint catches problematic or error-prone code patterns, and Prettier normalizes how every file is formatted so that diffs stay focused on actual logic changes rather than whitespace noise.
Linting vs. formatting — two different jobs
It's easy to conflate ESLint and Prettier because both run on save and both complain about your code, but they solve different problems. Formatting is about layout — indentation, line length, quote style, trailing commas, where line breaks go. It has no opinion on whether your code is correct, only on how it looks. Linting is about code quality and correctness — unused variables, unreachable code, missing dependencies in a useEffect array, using == where === was intended, accessibility issues in JSX. A lint rule can catch an actual bug; a formatting rule never can.
Because they overlap in scope (both can have opinions on things like quote style), a well-configured project turns off ESLint's own formatting-related rules and lets Prettier own formatting exclusively, while ESLint focuses purely on code-quality rules. The eslint-config-prettier package does exactly this: it disables every ESLint rule that would conflict with Prettier, so the two tools never fight over the same line.
• Prettier: formatting only — indentation, quotes, line width, trailing commas, semicolons • ESLint: code quality and correctness — unused vars, bad patterns, React-specific hook rules, accessibility • eslint-config-prettier disables ESLint's formatting rules so Prettier is the single source of truth for layout
Configuring ESLint for TypeScript and React
Modern ESLint (v9+) uses a flat config file, eslint.config.js, which exports an array of configuration objects. For a TypeScript React project you combine several plugins: @typescript-eslint provides TypeScript-aware rules (like disallowing unused variables that the base JS rule can't see correctly in type-only contexts), eslint-plugin-react and eslint-plugin-react-hooks add React-specific rules (most importantly the rules-of-hooks and exhaustive-deps checks), and eslint-plugin-jsx-a11y catches accessibility problems directly in JSX.
The react-hooks/exhaustive-deps rule deserves special mention: it analyzes a useEffect, useMemo, or useCallback call and warns when a value used inside the callback is missing from the dependency array — a category of bug (stale closures) that is otherwise very easy to introduce and very hard to notice in review.
Configuring Prettier
Prettier is deliberately low-configuration by design — its whole premise is 'stop debating style, just run the formatter.' A .prettierrc file typically sets only a handful of options: semicolons on or off, single vs. double quotes, print width, tab width, and trailing comma style. Beyond those few knobs, Prettier makes the decisions for you, which is the point — it removes formatting bikeshedding from code review entirely.
A .prettierignore file (same syntax as .gitignore) excludes generated files, build output, and vendored code from formatting, since running Prettier over a minified bundle or an auto-generated file is wasteful and can even break it.
Editor integration
Both tools are most valuable when they run automatically as you type, not just when you remember to run a command. In VS Code, the ESLint and Prettier extensions surface lint errors as red/yellow squiggles inline and can be configured to auto-fix and auto-format on save via the editor.formatOnSave and editor.codeActionsOnSave settings. Sharing a .vscode/settings.json in the repository ensures every contributor gets the same on-save behavior without needing to configure it manually.
• editor.formatOnSave: true — run Prettier automatically when a file is saved • editor.codeActionsOnSave: { "source.fixAll.eslint": true } — auto-apply ESLint's fixable rules on save • A shared .vscode/settings.json and .vscode/extensions.json (recommending the ESLint/Prettier extensions) keeps the whole team consistent
Enforcing checks automatically: pre-commit hooks and CI
Editor integration is convenient but not enforceable — someone can still commit unformatted or lint-failing code if their editor isn't configured correctly, or if they commit from the command line. A pre-commit hook, typically set up with Husky plus lint-staged, runs ESLint and Prettier automatically against only the files being committed, right before the commit completes, blocking the commit if there are unfixable errors.
Pre-commit hooks are a convenience and a fast local feedback loop, but they are not a security boundary — hooks can be skipped with git commit --no-verify, and a contributor might not have hooks installed at all. The check that actually matters is the one in CI: a pipeline step that runs npm run lint and a Prettier check (prettier --check .) against every pull request, failing the build (and blocking merge, if branch protection requires the check to pass) if either tool reports a problem.
• Pre-commit (Husky + lint-staged): fast, local, catches issues before they're even pushed • CI check: the authoritative gate — cannot be bypassed by skipping local hooks • prettier --check . exits non-zero if any file is not already correctly formatted, without changing anything — ideal for CI • eslint . --max-warnings=0 in CI turns warnings into hard failures, preventing warning-count creep over time
Minimal ESLint flat config for a TypeScript React project
prettier (from eslint-config-prettier) is placed last so it can override and disable any formatting rules turned on by earlier configs. react-hooks/exhaustive-deps is set to "warn" rather than "error" in many teams so it doesn't block a commit for an intentionally omitted dependency — but it should never be silently ignored.
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import prettier from "eslint-config-prettier";
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
plugins: { react, "react-hooks": reactHooks },
rules: {
...react.configs.recommended.rules,
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
settings: { react: { version: "detect" } },
},
prettier, // must be last: disables formatting-related rules
];Code Examples
Every one of these is purely cosmetic. Prettier will reformat every file to match this configuration consistently, so no two developers' files can drift apart in style.
Prettier would happily format this file — the indentation and quotes are fine. But ESLint's exhaustive-deps rule flags that userId is used inside the effect but missing from the dependency array, meaning the effect will not re-run if userId changes, leaving user stale. This is a real bug, not a style issue.
}, []); // eslint: react-hooks/exhaustive-deps warning Common mistake: Suppressing the warning with // eslint-disable-next-line without actually deciding whether the missing dependency is intentional — this silences a real signal about stale data.
lint-staged runs ESLint and Prettier only on the files staged for commit, keeping the pre-commit check fast even in a large repo. husky's prepare script installs the git hook automatically when a teammate runs npm install.
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always"
}function UserPanel({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // eslint: react-hooks/exhaustive-deps warning — 'userId' is missing
return <div>{user?.name}</div>;
}{
"scripts": {
"lint": "eslint . --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"]
}
}Where checks run, from keystroke to merge
Editor integration and pre-commit hooks are fast local conveniences; the CI check is the one that actually gates merging.
Editor (on save)
│ ESLint + Prettier auto-fix
▼
git commit
│ Husky pre-commit hook → lint-staged
│ (ESLint --fix + Prettier --write on staged files)
▼
git push → Pull Request
│
▼
CI pipeline
│ eslint . --max-warnings=0
│ prettier --check .
▼
Merge blocked until both checks passImportant Rules
Let Prettier own all formatting decisions; use eslint-config-prettier so ESLint never fights it over layout.
Enable react-hooks/rules-of-hooks and react-hooks/exhaustive-deps — they catch real, hard-to-spot bugs.
Configure format-on-save in the editor, but never rely on it as the only enforcement mechanism.
Run both eslint and prettier --check in CI on every pull request; treat lint warnings as failures with --max-warnings=0.
Commit shared editor config (.vscode/settings.json) so the whole team gets the same on-save behavior.
Why this matters in production
Teams that skip a shared lint/format setup end up with pull requests dominated by whitespace and quote-style diffs that obscure the actual logic change, making code review slower and more error-prone. Teams that enforce exhaustive-deps in CI catch stale-closure bugs — one of the most common sources of 'the UI shows old data' bug reports in React apps — before they ever reach a user.
Configuring ESLint's own formatting rules (like indent or quotes) alongside Prettier.
Use eslint-config-prettier to disable ESLint's formatting rules and let Prettier be the single source of truth.
When both tools have opinions on the same formatting concern, they can produce conflicting auto-fixes that fight each other on every save.
Relying only on a pre-commit hook to enforce lint/format rules, with no CI check.
Add an equivalent lint and format check as a required CI step on pull requests.
Pre-commit hooks can be bypassed with --no-verify or simply not installed by a contributor; CI is the only enforcement point nobody can skip.
What is the key difference in responsibility between ESLint and Prettier? A. ESLint only works with JavaScript, Prettier only works with TypeScript B. Prettier handles formatting/layout; ESLint handles code quality and correctness C. They do the exact same thing and either can be used alone D. ESLint runs in the browser; Prettier runs only on the server
B. Prettier handles formatting/layout; ESLint handles code quality and correctness — Prettier is purely about visual layout (indentation, quotes, line breaks); ESLint analyzes code for bugs, bad patterns, and quality issues that formatting alone cannot detect.
What does the react-hooks/exhaustive-deps ESLint rule check for? A. That every component has at least one hook B. That values used inside useEffect/useMemo/useCallback are included in the dependency array C. That hooks are only called in class components D. That Prettier formatting is applied to hook calls
B. That values used inside useEffect/useMemo/useCallback are included in the dependency array — It flags missing dependencies, which commonly cause stale-closure bugs where an effect doesn't re-run when a value it depends on changes.
Why is a CI lint/format check considered the authoritative enforcement point, more than a pre-commit hook? A. CI checks run faster than pre-commit hooks B. Pre-commit hooks can be skipped (e.g. --no-verify) or simply not installed, while CI runs unconditionally on every PR C. Pre-commit hooks cannot run Prettier D. CI checks automatically fix code, while pre-commit hooks only report errors
B. Pre-commit hooks can be skipped (e.g. --no-verify) or simply not installed, while CI runs unconditionally on every PR — A local hook is a convenience that depends on developer setup and can be bypassed; the CI check is enforced centrally and cannot be skipped by an individual contributor.
Set up ESLint and Prettier in a fresh TypeScript React project: install the packages, write eslint.config.js and .prettierrc, add lint/format scripts to package.json, and configure Husky + lint-staged so committing a badly formatted file with an unused import is automatically fixed or blocked. Hint: Install typescript-eslint, eslint-plugin-react, eslint-plugin-react-hooks, eslint-config-prettier, prettier, husky, and lint-staged as devDependencies first.
Try it yourself — there's no single right answer here; compare your result against this lesson's examples.