Hard limits
"Keep components small" is advice nobody can apply, because it isn't checkable. Two people disagree on "small" and the discussion dies on taste.
A limit with a number fixes that. The number doesn't have to be perfect — it has to be agreed on.
The table
| What | Limit | Action when exceeded |
|---|---|---|
Component .tsx file |
150 | extract a sub-component or a hook |
| Body of a function/component | 80 | extract a pure function or a hook |
| Custom hook | 100 | split into smaller hooks |
| Props on a component | 7 | it's probably two components |
| JSX nesting depth | 4 | extract a sub-component |
| Cyclomatic complexity | 10 | lookup table or early return |
| Function parameters | 3 | make it one named object |
Service/util .ts file |
200 | split by responsibility |
Why 150 and not 200 or 100?
150 lines is what fits in two editor screens. It's the point where you can still read the whole file before changing one line — and reading the whole file is what prevents the change that breaks another part of it. Past that you start editing by search, not by understanding.
What the number actually measures
Lines are a proxy. What you're really limiting is:
| Symptom | Real consequence |
|---|---|
| Long file | nobody reads it whole → change with surprise effects |
| Long function | doesn't fit in your head → intermediate-state bug |
| Many props | many cases → invalid combination representable |
| Deep JSX | structure invisible → CSS/layout breaks |
| High complexity | untested paths → bug in a rare branch |
That's why it's worth counting lines of code, not lines of file: JSDoc and the
props interface don't add cognitive load — they reduce it.
Dogfood: where the SDK itself lands
Real numbers from this repository (157 production .tsx files, counting code lines
only):
| Metric | Value |
|---|---|
| Median lines per file | 65 |
| Files above 150 | 28 |
| Largest file | FilterBar.tsx — 279 |
In other words: the limit is not honored in 100% of cases, on purpose. The median
is 65 because the rule works; the 28 that exceed it are widgets with irreducible
behaviour — ImageCropper (drag + zoom + canvas crop), Calendar (grid + keyboard
+ range), BrazilMap (SVG + hit-testing).
The escape hatch: @tempest-limits
Exceeding a limit is acceptable when you write the reason. The marker is
@tempest-limits <rule> — <reason> in a comment in the file:
/**
* Interactive image cropper.
*
* @tempest-limits file-lines — pointer drag, wheel zoom, aspect-ratio clamping
* and canvas export share one piece of geometry state. Splitting them would mean
* threading that state through props and duplicating the clamp maths.
*/
- The rule is an id from the table below (
file-lines,props-count, …), several separated by commas, or*for all of them. - The reason comes after
—,-or:. Fewer than 12 characters does not count as a reason:tempest doctorreports the empty marker, because an unexplained waiver is exactly what it exists to prevent.
What is not acceptable is exceeding it without noticing
The marker isn't bureaucracy — it's what turns "this file is big" from an accident into a decision. Whoever reads it in six months knows whether they may split it.
eslint-disable counts too
An existing // eslint-disable-next-line @typescript-eslint/no-explicit-any
already silences that line's any in doctor — the standard mechanism wins,
no second marker needed.
tempest doctor already enforces this
The CLI has a Design section that measures the project and reports each violation with a file and a line:
npx tempest doctor # includes the design analysis
npx tempest doctor --no-design # skips the section
Design
[i] 42 source file(s) · 1830 lines of code · median 38 — largest: src/pages/Orders.tsx (204)
[!] src/pages/Orders.tsx:1 — 204 lines of code (limit 150) — extract a sub-component, a hook or a pure function
[!] src/pages/Orders.tsx:31 — a component must not call the network — move it to a service and read it with useQuery
[!] src/features/orders/OrderTable.tsx:12 — OrderTableProps has 9 props (limit 7) — likely two components in one
[i] 2 limit(s) waived with a written reason — @tempest-limits markers — nothing to do
The rules, and each one's id for the marker:
| Id | What it measures |
|---|---|
file-lines |
lines of code in the file (150 .tsx / 200 .ts) |
function-lines |
function/component body (80) |
hook-lines |
use* hook body (100) |
props-count |
members of <X>Props, or destructured props (7) |
param-count |
parameters of an exported function (3) |
explicit-any |
any in a type position, as any |
ts-ignore |
@ts-ignore / @ts-nocheck |
fetch-in-component |
fetch(/axios in a .tsx |
empty-catch |
a catch with an empty body |
inline-style-literal |
a literal colour inside style={{ … }} |
The Design section never fails the exit code
Every finding is a warn or a note. A limit is a heuristic with a written
escape hatch — failing CI on a heuristic is the fastest way to get the tool
silenced. The hard gates stay where they belong: no-explicit-any as an ESLint
error and tsc --noEmit.
What the analysis does not judge
Three classes of file drop out before any rule runs, because the answer to a finding in them is not in the file:
- Generated or vendored code — any file whose head carries
@generatedorDo not hand-edit. That shape is upstream's, and an edit here dies at the next regeneration. This SDK'ssrc/vision/is one: every file comes out ofnpm run vendor:visionalready stamped. - Barrels — a file that only re-exports. A 350-line
index.tsofexport { X } from "./x"is a directory listing, and "extract a sub-component" means nothing there. - Test files — for the size rules. A long test is usually a thorough one.
One props count, not two
When a component destructures exactly the props its <Name>Props declares, the
finding is reported once, on the type. Two lines for one fact read as two
problems. The destructuring only earns its own finding when it goes past what the
type declares — that is when it is taking props from somewhere else.
What doctor does not measure
JSX nesting depth and cyclomatic complexity. Both need a real parser to avoid
false positives on Prettier line wrapping — they stay with ESLint (max-depth,
complexity) and with review.
Making the linter enforce it too
Add this to your app's eslint.config.js (the
scaffold template ships the base; this is the design layer):
{
files: ["**/*.{ts,tsx}"],
rules: {
"max-lines": [
"warn",
{ max: 150, skipBlankLines: true, skipComments: true },
],
"max-lines-per-function": [
"warn",
{ max: 80, skipBlankLines: true, skipComments: true },
],
"max-depth": ["warn", 4],
"max-params": ["warn", 3],
complexity: ["warn", 10],
"@typescript-eslint/no-explicit-any": "error",
},
},
{
// Tests describe scenarios; counting them as production code only produces
// noise and encourages less readable tests.
files: ["**/*.test.{ts,tsx}"],
rules: {
"max-lines": "off",
"max-lines-per-function": "off",
},
},
warn on size, error on typing
A size limit is a heuristic — error turns every legitimate exception into an
eslint-disable, and scattered eslint-disable is worse than a big file. warn
shows up in the PR and someone decides. no-explicit-any, on the other hand, is
error: there is no legitimate case worth the silence (see
Strong typing).
Running it:
npx tempest lint # ESLint with the project config
npx tempest lint --max-warnings 0 # in CI, when you want the limit to be hard
CLI details in tempest CLI.
Finding today's violations
Before turning the rule on, size up the problem:
# Top 15 files by lines of code (ignores blank, // and /* */ blocks)
find src -name "*.tsx" ! -name "*.test.tsx" | while read -r f; do
n=$(grep -vcE '^\s*($|//|/\*|\*|\*/)' "$f")
echo "$n $f"
done | sort -rn | head -15
If 3 files show up above the limit, fix them today. If 60 do, turn the rule on as
warn and fix what you were already going to touch — a mass refactor produces an
unreviewable PR and no immediate gain.
The three cuts that solve 90% of cases
1. A sub-component per JSX block
The 300-line .tsx is almost always 4 visual blocks in one file. Give each block a
name:
OrderDetail.tsx (300)
└── OrderDetail.tsx (60) + OrderHeader.tsx (50) + OrderItems.tsx (70) + OrderTotals.tsx (40)
2. A hook for the logic
State + effects + handlers move to use-<thing>.ts. The .tsx keeps only markup.
Full example in
Thinking in components.
3. A pure function outside React
sortBy, paginate, formatInvoice, buildQuery don't need React — they go to
lib/ or the feature file, and get a cheap unit test.
A cut that isn't worth it: moving JSX into a function in the same file
function renderHeader() { … } // ❌ not a component, not reusable
Recap
- Limits become numbers so they stop being a matter of taste: 150 file, 80 function, 100 hook, 7 props, 4 nesting, 10 complexity.
- Lines are a proxy for cognitive load — count code lines, not JSDoc.
- The SDK's median is 65 with 28 deliberate violations; the escape hatch is
@tempest-limits <rule> — <reason>, never silence. npx tempest doctormeasures and reports with file and line (alwayswarn); ESLint enforces withmax-linesandno-explicit-anyaserror.- Three cuts solve nearly everything: sub-component, hook, pure function.
Next: Strong typing.