
Clean Code Ts React
- 113 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
clean-code-ts-react is a Claude Code skill for frontend development.
About
clean-code-ts-react is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
- clean-code-ts-react
- Frontend Development
- AI-coding skill
Clean Code Ts React by the numbers
- 113 all-time installs (skills.sh)
- +9 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,025 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill clean-code-ts-reactAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 113 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with frontend development tasks during AI-assisted development.?
Helps with frontend development tasks during AI-assisted development.
Who is it for?
Best when you're working on frontend development and need structured help with clean code ts react.
Skip if: Teams with no frontend development needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with frontend development tasks during AI-assisted development., or when clean-code-ts-react is a claude code skill for frontend development.
What you get
Structured output aligned to clean-code-ts-react: clean-code-ts-react, Frontend Development.
Files
Robert C. Martin (Uncle Bob) TypeScript 5.x + React 19 Best Practices
Craftsmanship principles from Robert C. Martin's Clean Code (2008), re-expressed for modern TypeScript and React. Contains 61 rules across 11 categories, prioritized by cognitive cost across a code change's lifetime. Examples use TS 5.x and React 19 idioms — but the rules are about timeless principles, not specific APIs.
What Makes This Skill Different
Three things set this apart from a generic clean-code copy:
1. Modern idioms as vehicle. Examples use TS 5.x (satisfies, branded types, discriminated unions, const type parameters) and React 19 (function components, hooks, use(), Server Components where relevant). But the rule is always the principle, never the syntax. 2. "When NOT to apply" is first-class. Every rule has 2-3 concrete scenarios where the principle should bend — not generic disclaimers, real situations. Loop counters can be i. Single-use code shouldn't be DRY. Some HOCs are unavoidable. 3. Meta category for principle conflicts. Category 11 names the most common tensions explicitly — DRY vs Single Responsibility, small functions vs deep modules (Ousterhout), type precision vs ergonomic APIs, tests as spec vs documentation. The mark of seniority is knowing which to bend.
When to Apply
Reference these guidelines when:
- Writing new TypeScript or React code and wanting craftsmanship feedback
- Reviewing a pull request for clarity, naming, or abstraction
- Refactoring existing code for readability or maintainability
- Designing function, hook, or component APIs
- Deciding whether to extract, abstract, or duplicate
- Resolving a tension between two clean-code rules (see Category 11)
Skip this skill and use:
- `react` for React 19 API patterns (concurrent rendering, Server Components, ref-as-prop,
useActionState,<Context>-as-provider) - `typescript` for compiler performance, tsconfig tuning, type-system perf
- `refactor` for mechanical refactoring workflows
- `tdd` for the TDD workflow itself
Rule Categories by Priority
Order reflects cognitive cost across a change's lifetime (read → understand → modify → verify → ship → maintain). Earlier stages cascade — bad names taint every read.
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Meaningful Names | CRITICAL | name- | 8 |
| 2 | Functions, Components & Hooks | CRITICAL | func- | 8 |
| 3 | Self-Documentation (Types & Comments) | HIGH | doc- | 5 |
| 4 | Formatting (Beyond Prettier) | HIGH | fmt- | 4 |
| 5 | Error Handling | HIGH | err- | 7 |
| 6 | Data Shape & Immutability | MEDIUM-HIGH | data- | 6 |
| 7 | Boundaries | MEDIUM-HIGH | bound- | 4 |
| 8 | Composition over Inheritance | MEDIUM-HIGH | comp- | 6 |
| 9 | Tests | MEDIUM | test- | 5 |
| 10 | Emergence & Simple Design | MEDIUM | emerge- | 4 |
| 11 | Meta: When Principles Conflict | MEDIUM | meta- | 4 |
Total: 61 rules.
Quick Reference
1. Meaningful Names (CRITICAL)
- `name-intention-revealing` — Use names that reveal intent
- `name-avoid-disinformation` — Avoid misleading names
- `name-meaningful-distinctions` — Make meaningful distinctions
- `name-component-pascal-case` — Components are PascalCase noun phrases
- `name-hook-use-prefix` — Hooks are
useXverb phrases - `name-handler-convention` — Event handlers use
onX/handleX - `name-boolean-predicate` — Boolean variables use
is/has/can - `name-types-pascal-case` — Types and interfaces are PascalCase
2. Functions, Components & Hooks (CRITICAL)
- `func-small` — Keep functions, components & hooks small
- `func-one-thing` — Do one thing
- `func-abstraction-level` — One level of abstraction per function
- `func-minimize-arguments` — Prefer object parameters over long lists
- `func-no-side-effects` — Avoid hidden side effects (especially in render)
- `func-command-query-separation` — Separate commands from queries
- `func-dry` — DRY — until concepts diverge
- `func-custom-hook-extract` — Extract custom hooks for reusable stateful logic
3. Self-Documentation: Types & Comments (HIGH)
- `doc-types-over-comments` — Prefer types over comments
- `doc-satisfies-narrows-with-check` — Use
satisfiesfor inferred-but-checked values - `doc-jsdoc-public-api` — JSDoc for public APIs and non-obvious side effects
- `doc-avoid-redundant-comments` — Avoid redundant comments
- `doc-delete-commented-out-code` — Delete commented-out code
4. Formatting Beyond Prettier (HIGH)
- `fmt-vertical-density` — Keep related code close, unrelated far
- `fmt-newspaper-order` — Order files top-down like a newspaper
- `fmt-team-rules-over-preference` — Team conventions over personal preference
- `fmt-imports-grouped` — Group imports by source
5. Error Handling (HIGH)
- `err-early-return` — Use early returns to flatten error paths
- `err-result-vs-throw` — Choose throw vs Result deliberately
- `err-narrow-unknown` — Always narrow
unknownin catch blocks - `err-error-boundaries` — Use error boundaries for render-time failures
- `err-suspense-for-loading` — Use Suspense for loading states
- `err-no-swallow` — Never swallow errors silently
- `err-null-vs-undefined` — Pick
nullORundefinedper domain
6. Data Shape & Immutability (MEDIUM-HIGH)
- `data-discriminated-unions-over-flags` — Discriminated unions over boolean flags
- `data-readonly-by-default` — Mark read-only data
readonly - `data-branded-types` — Brand types for domain invariants
- `data-dto-vs-domain` — Separate DTOs from domain types
- `data-demeter-prop-drilling` — Prop drilling often smells like Demeter
- `data-structural-typing-pitfalls` — Beware structural typing aliasing
7. Boundaries (MEDIUM-HIGH)
- `bound-wrap-third-party-hooks` — Wrap third-party hooks in custom hooks
- `bound-learning-tests` — Write learning tests for third-party behavior
- `bound-isolate-framework` — Isolate framework-specific code at the edges
- `bound-type-assertions-at-edges` — Type assertions belong only at boundaries
8. Composition over Inheritance (MEDIUM-HIGH)
- `comp-children-over-props` — Compose with
childrenover configuration props - `comp-small-components` — Keep components small and cohesive
- `comp-avoid-hoc-stacks` — Avoid higher-order component stacks
- `comp-context-only-when-needed` — Context for DI, not prop avoidance
- `comp-render-props-vs-hooks` — Prefer hooks over render props for logic reuse
- `comp-separate-construction-from-use` — Separate setup from rendering
9. Tests (MEDIUM)
- `test-behavior-not-implementation` — Test behavior, not implementation
- `test-mock-at-boundaries` — Mock only at true boundaries
- `test-first-principles` — Apply FIRST principles
- `test-one-concept` — One concept (not one assert) per test
- `test-clean-as-production` — Test code deserves production-grade care
10. Emergence & Simple Design (MEDIUM)
- `emerge-four-rules` — Apply the four rules of simple design in order
- `emerge-yagni-types` — Avoid premature type generics
- `emerge-premature-abstraction` — Resist premature abstraction
- `emerge-reveal-intent` — Maximize expressiveness — code as communication
11. Meta: When Principles Conflict (MEDIUM)
This is the signature category — explicit guidance on when one clean-code principle yields to another.
- `meta-dry-vs-srp` — Bend DRY when concepts drift apart
- `meta-small-vs-deep` — Small functions lose to deep modules when indirection > comprehension
- `meta-types-vs-ergonomics` — Type safety loses to ergonomics at stable boundaries
- `meta-tests-as-spec-vs-doc` — Pick tests-as-spec or tests-as-documentation per file
How to Use
For an ad-hoc question ("is this naming OK?", "should I extract this?"), jump straight to the relevant rule file via the Quick Reference above.
For a code review or refactor, scan the categories in priority order — names and function shape first (highest cascade), then errors and data shape, then composition and tests. The category-major sweep is more efficient than file-major.
When two principles seem to disagree, read the corresponding Meta rule (Category 11). Pick the principle that wins, and document the call.
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for adding new rules |
| metadata.json | Version and reference information |
Related Skills
.experimental/clean-code— Original language-agnostic clean code (Java examples). This skill is the TS+React sibling..curated/react— React 19-specific patterns (Server Components, concurrent rendering, ref-as-prop)..curated/typescript— TS compiler performance and tsconfig tuning..curated/refactor— Mechanical refactoring workflows..curated/tdd— The TDD workflow itself.
TypeScript 5.x + React 19 (principles are language-agnostic; examples use modern TS+React idioms)
Version 0.1.0 Robert C. Martin (Uncle Bob) — adapted for TypeScript + React May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Software craftsmanship guide translating Robert C. Martin's universal Clean Code principles into modern TypeScript and React. Contains 61 rules across 11 categories — names, functions/components/hooks, self-documentation via types, formatting beyond Prettier, error handling, data shape and immutability, boundaries, composition, tests, emergence, and a unique Meta category for when clean-code principles conflict (DRY vs SRP, small functions vs deep modules, type safety vs ergonomics, tests as spec vs documentation). Every rule includes incorrect and correct examples in TS 5.x + React 19 with minimal diffs, plus first-class 'When NOT to apply' sections with concrete scenarios. Sister skill to .experimental/clean-code; defers framework-specific patterns to .curated/react and TS compiler performance to .curated/typescript.
---
Table of Contents
1. Meaningful Names — CRITICAL
- 1.1 Avoid Misleading Names — CRITICAL (prevents readers from acting on false assumptions baked into a name)
- 1.2 Boolean Variables Use is/has/can Prefixes — CRITICAL (prevents reading ambiguity at boolean call sites)
- 1.3 Components Are PascalCase Noun Phrases — CRITICAL (enables JSX to render correctly and signals "this is a thing on the page")
- 1.4 Event Handlers Use onX / handleX Convention — CRITICAL (prevents prop-handler contract drift across components)
- 1.5 Hooks Are useX Verb Phrases — CRITICAL (prevents hook-rule lint bypass at hook call sites)
- 1.6 Make Meaningful Distinctions — CRITICAL (prevents noise-word naming collisions)
- 1.7 Types and Interfaces Are PascalCase — CRITICAL (prevents type-vs-value confusion at every read)
- 1.8 Use Intention-Revealing Names — CRITICAL (eliminates the mental mapping a reader must do on every read)
2. Functions, Components & Hooks — CRITICAL
- 2.1 Avoid Hidden Side Effects (Especially in Render) — CRITICAL (preserves React's purity contract and makes function behavior predictable)
- 2.2 Do Not Repeat Yourself — Until the Concepts Diverge — CRITICAL (collapses duplicated concepts into one source of truth without coupling unrelated code)
- 2.3 Do One Thing — CRITICAL (prevents mixed-abstraction comprehension cost)
- 2.4 Extract Custom Hooks for Reusable Stateful Logic — CRITICAL (makes stateful behavior reusable without HOCs, render props, or context gymnastics)
- 2.5 Keep Functions, Components & Hooks Small — CRITICAL (prevents working-memory overflow on every read)
- 2.6 One Level of Abstraction Per Function — CRITICAL (prevents the reader from context-switching between strategy and mechanics)
- 2.7 Prefer Object Parameters Over Long Argument Lists — CRITICAL (eliminates positional-argument errors and makes refactors safe)
- 2.8 Separate Commands from Queries — CRITICAL (makes the call site read as either an action or a question, never both)
3. Self-Documentation: Types & Comments — HIGH
- 3.1 Avoid Redundant Comments — HIGH (removes noise that decays into lies when code changes)
- 3.2 Delete Commented-Out Code — HIGH (stops dead code from accumulating as cognitive tax on every future reader)
- 3.3 JSDoc for Public APIs and Non-Obvious Side Effects — HIGH (surfaces intent and side effects at the call site where consumers actually look)
- 3.4 Prefer Types Over Comments — HIGH (eliminates stale-doc decay by promoting invariants to compiler-checked types)
- 3.5 Use `satisfies` for Inferred-But-Checked Values — HIGH (preserves literal-type precision while still verifying shape against a contract)
4. Formatting (Beyond Prettier)) — HIGH
- 4.1 Group Imports by Source — HIGH (makes dependency provenance scannable at a glance)
- 4.2 Keep Related Code Close, Unrelated Code Far — HIGH (reduces eye-tracking and working-memory cost when reading a function)
- 4.3 Order Files Top-Down Like a Newspaper — HIGH (lets readers grasp a file's purpose without scrolling to find the headline)
- 4.4 Team Conventions Over Personal Preference — HIGH (trades local optimization for codebase-wide consistency, which is what readers actually need)
5. Error Handling — HIGH
- 5.1 Always Narrow `unknown` in Catch Blocks — HIGH (prevents secondary crashes from assuming caught values are
Errorinstances) - 5.2 Choose Throw vs Result Deliberately — HIGH (forces callers to handle predictable failures at the type level)
- 5.3 Never Swallow Errors Silently — HIGH (prevents silent bug factories at every catch site)
- 5.4 Pick null OR undefined Per Domain — Not Both — HIGH (removes the mental tax of remembering which absence sentinel each function uses)
- 5.5 Use Early Returns to Flatten Error Paths — HIGH (keeps the happy path at one indent level so readers can find it)
- 5.6 Use Error Boundaries for Render-Time Failures — HIGH (contains component crashes so one bad subtree doesn't blank the whole app)
- 5.7 Use Suspense for Loading States, Not Boolean Flags — HIGH (declares loading once at the boundary instead of repeating conditionals in every component)
6. Data Shape & Immutability — MEDIUM-HIGH
- 6.1 Beware Structural Typing Aliasing — MEDIUM-HIGH (prevents semantically distinct types from being silently interchangeable)
- 6.2 Brand Types to Make Domain Distinctions Compile-Checked — MEDIUM-HIGH (turns argument-swap bugs into compile errors)
- 6.3 Mark Read-Only Data Readonly — MEDIUM-HIGH (prevents silent mutation that React won't detect)
- 6.4 Prop Drilling Often Smells Like Demeter Violation — MEDIUM-HIGH (reduces structural coupling between distant components)
- 6.5 Separate DTOs from Domain Types — MEDIUM-HIGH (localizes API changes to a translation layer)
- 6.6 Use Discriminated Unions Over Boolean Flags — MEDIUM-HIGH (encodes legal states only so the compiler enforces invariants)
7. Boundaries — MEDIUM-HIGH
- 7.1 Isolate Framework-Specific Code at the Edges — MEDIUM-HIGH (keeps business logic testable and framework-agnostic)
- 7.2 Type Assertions Belong Only at Boundaries — MEDIUM-HIGH (confines unchecked trust to one verified entry point)
- 7.3 Wrap Third-Party Hooks in Custom Hooks — MEDIUM-HIGH (localizes library-version churn to a single file)
- 7.4 Write Learning Tests for Third-Party Behavior — MEDIUM-HIGH (alarms on silent library behavior changes during upgrades)
8. Composition over Inheritance — MEDIUM-HIGH
- 8.1 Avoid Higher-Order Component Stacks — MEDIUM-HIGH (makes injected dependencies visible and typeable)
- 8.2 Compose with Children Over Configuration Props — MEDIUM-HIGH (inverts variation from props-explosion to caller composition)
- 8.3 Keep Components Small and Cohesive — MEDIUM-HIGH (bounds what a single component has to be understood about)
- 8.4 Prefer Hooks Over Render Props for Logic Reuse — MEDIUM-HIGH (simplifies composition and improves type inference)
- 8.5 Separate Setup (Effects, Subscriptions) from Rendering — MEDIUM-HIGH (keeps render pure and aligns with React's contract)
- 8.6 Use Context for True Dependency Injection, Not Prop Avoidance — MEDIUM-HIGH (avoids hidden coupling and re-render cascades)
9. Tests — MEDIUM
- 9.1 Apply FIRST Principles to Tests — MEDIUM (Fast, isolated, deterministic tests get run; slow flaky ones get skipped)
- 9.2 Mock Only at True Boundaries — MEDIUM (Real code paths get exercised; refactors stay safe)
- 9.3 One Concept (Not One Assert) Per Test — MEDIUM (Each test names a behavior; assertions describe it together)
- 9.4 Test Behavior, Not Implementation — MEDIUM (Tests stay green through refactors, red through real regressions)
- 9.5 Test Code Deserves Production-Grade Care — MEDIUM (Readable tests make production code feel safe to change)
10. Emergence & Simple Design — MEDIUM
- 10.1 Apply the Four Rules of Simple Design in Order — MEDIUM (Refactor toward simplicity without breaking behavior or clarity)
- 10.2 Avoid Premature Type Generics — MEDIUM (Concrete types stay readable; generics earn their complexity)
- 10.3 Maximize Expressiveness — Code as Communication — MEDIUM (Names and structure communicate purpose; bytecode is a side effect)
- 10.4 Resist Premature Abstraction — MEDIUM (Avoid the wrong abstraction; let patterns emerge from concrete cases)
11. Meta: When Principles Conflict — MEDIUM
- 11.1 Bend DRY When Concepts Drift Apart — MEDIUM (prevents wrong-abstraction lock-in across modules)
- 11.2 Pick Tests-as-Spec or Tests-as-Documentation Per File — MEDIUM (prevents audience-mixing in test files)
- 11.3 Small Functions Lose to Deep Modules When Indirection Exceeds Comprehension — MEDIUM (prevents shallow-module fragmentation)
- 11.4 Type Safety Loses to Ergonomics at Stable Boundaries — MEDIUM (prevents impossible-state representations at compile time)
---
References
1. https://www.oreilly.com/library/view/clean-code-a/9780136083238/ 2. https://web.stanford.edu/~ouster/cgi-bin/aposd.php 3. https://www.totaltypescript.com/ 4. https://kentcdodds.com/ 5. https://tkdodo.eu/ 6. https://react.dev/ 7. https://www.typescriptlang.org/docs/handbook/ 8. https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction 9. https://kentbeck.github.io/TestDesiderata/
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
Brief explanation (1-3 sentences) of WHY this matters. Focus on the principle and its impact on cognitive cost, change locality, or invariant preservation — not on rote rules. The model should be able to apply the principle in novel situations.
Incorrect (description of what's wrong):
// Modern TS+React example showing the anti-pattern.
// Comment explaining the cost (what the reader/modifier has to do extra).Correct (description of what's right):
// Same code, minimal diff, showing the principle applied.
// Comment explaining the benefit (what the reader/modifier saves).When NOT to apply this pattern:
- Concrete scenario where the principle is rightly bent (not a generic disclaimer).
- Second concrete scenario, ideally one that highlights a known tension with another principle.
Why this matters: One line tying the rule back to the underlying principle (cognitive load, change locality, invariant preservation, etc.) — what the rule is really about.
Reference: Clean Code, Chapter N: Topic — and a modern counter-source where the original advice is contested (e.g., Ousterhout, *A Philosophy of Software Design*).
{
"version": "1.0.2",
"organization": "Robert C. Martin (Uncle Bob) — adapted for TypeScript + React",
"technology": "TypeScript 5.x + React 19 (principles are language-agnostic; examples use modern TS+React idioms)",
"discipline": "distillation",
"type": "code-quality",
"date": "May 2026",
"abstract": "Software craftsmanship guide translating Robert C. Martin's universal Clean Code principles into modern TypeScript and React. Contains 61 rules across 11 categories — names, functions/components/hooks, self-documentation via types, formatting beyond Prettier, error handling, data shape and immutability, boundaries, composition, tests, emergence, and a unique Meta category for when clean-code principles conflict (DRY vs SRP, small functions vs deep modules, type safety vs ergonomics, tests as spec vs documentation). Every rule includes incorrect and correct examples in TS 5.x + React 19 with minimal diffs, plus first-class 'When NOT to apply' sections with concrete scenarios. Sister skill to .experimental/clean-code; defers framework-specific patterns to .curated/react and TS compiler performance to .curated/typescript.",
"references": [
"https://www.oreilly.com/library/view/clean-code-a/9780136083238/",
"https://web.stanford.edu/~ouster/cgi-bin/aposd.php",
"https://www.totaltypescript.com/",
"https://kentcdodds.com/",
"https://tkdodo.eu/",
"https://react.dev/",
"https://www.typescriptlang.org/docs/handbook/",
"https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction",
"https://kentbeck.github.io/TestDesiderata/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
Categories are ordered by cognitive cost across a code change's lifetime (read → understand → modify → verify → ship → maintain). Earlier stages cascade — bad names taint every read, bad function shapes taint every modify.
---
1. Meaningful Names (name)
Impact: CRITICAL Description: Names are read far more often than they are written. Bad names cascade confusion through every future read, review, and modification — they are the single highest-leverage form of documentation in TS+React code.
2. Functions, Components & Hooks (func)
Impact: CRITICAL Description: Functions, components, and hooks are the units of comprehension in TS+React. Small, focused units enable understanding, testing, and reuse — but pursued blindly they create "shallow modules" that scatter logic. The principle is comprehensibility, not line count.
3. Self-Documentation: Types & Comments (doc)
Impact: HIGH Description: In TypeScript, types are the primary documentation mechanism — they are checked, refactored, and read by the compiler. Comments are a fallback for the rare cases types cannot express. Stale or redundant comments cost more than no comments.
4. Formatting (Beyond Prettier) (fmt)
Impact: HIGH Description: Prettier and ESLint handle whitespace and syntax. What remains is human judgment: vertical density, reading order, file organization, and team consistency. A file should read top-to-bottom like a newspaper article — high-level concepts first, details below.
5. Error Handling (err)
Impact: HIGH Description: Clean error handling separates the happy path from exceptional cases. In TS+React this means narrowing as the primitive, error boundaries and Suspense as the framework-level pattern, and a deliberate choice between throwing exceptions and returning Result-like discriminated unions. Swallowed errors are silent bugs.
6. Data Shape & Immutability (data)
Impact: MEDIUM-HIGH Description: Type shapes encode invariants. Discriminated unions over boolean flags, readonly where mutation is not intended, branded types for opaque identifiers — these turn "could not happen" comments into compile errors. Prop-drilling smells like a Law-of-Demeter violation, but is sometimes the right call.
7. Boundaries (bound)
Impact: MEDIUM-HIGH Description: Third-party hooks, SDKs, and external APIs are boundaries. Wrap them in custom hooks or thin adapters to isolate change, ease testing, and keep your codebase's vocabulary stable when a vendor swaps an interface. Type assertions belong at boundaries, never in the middle.
8. Composition over Inheritance (comp)
Impact: MEDIUM-HIGH Description: React composes via children, render props, and hooks — not class hierarchies. Keep components cohesive, prefer composition over HOC stacks, and inject dependencies via context only when prop-passing genuinely hurts. The 2008 advice on "small classes" maps to "small components and hooks" in modern React.
9. Tests (test)
Impact: MEDIUM Description: Tests are first-class code that enables safe refactoring. In React Testing Library and Vitest terms: test behavior not implementation, mock only at true boundaries (not your own modules), one concept per test, and apply the same naming and structure discipline as production code.
10. Emergence & Simple Design (emerge)
Impact: MEDIUM Description: Good design emerges from four rules applied in order: passes tests, reveals intent, no duplication, fewest elements. Premature abstraction is the most common violation in modern TS+React — generic gymnastics, HOC over composition, and "just-in-case" hooks all fail rule four.
11. Meta: When Principles Conflict (meta)
Impact: MEDIUM Description: Clean code principles routinely conflict — DRY vs Single Responsibility, small functions vs deep modules, type safety vs ergonomic APIs. The mark of seniority is knowing which principle to bend in a given context. This category gives explicit guidance for the most common conflicts.
Isolate Framework-Specific Code at the Edges
Business logic that imports react, next/navigation, or next-auth is coupled to those framework versions and runtimes. Core logic should be plain TypeScript that returns plain values; let the edges (components, route handlers, server actions) translate between the framework and the core. The pure core is trivially testable, runs anywhere, and survives framework migrations.
Incorrect (validation logic is secretly a React hook):
// validateCheckout calls useToast — now it's a hook, can't be tested
// without a renderer, can't run server-side, can't be reused outside React.
function validateCheckout(cart: Cart) {
const toast = useToast();
if (cart.items.length === 0) {
toast.error('Cart is empty');
return false;
}
if (cart.total < 0.01) {
toast.error('Invalid total');
return false;
}
return true;
}Correct (pure core; UI concerns live in the component):
// Pure: testable with `expect(validateCheckout(cart)).toEqual(...)`.
type ValidationResult =
| { ok: true }
| { ok: false; reason: 'empty' | 'invalid-total' };
function validateCheckout(cart: Cart): ValidationResult {
if (cart.items.length === 0) return { ok: false, reason: 'empty' };
if (cart.total < 0.01) return { ok: false, reason: 'invalid-total' };
return { ok: true };
}
function CheckoutButton({ cart }: { cart: Cart }) {
const toast = useToast();
const onClick = () => {
const result = validateCheckout(cart);
if (!result.ok) toast.error(messageFor(result.reason));
else submitOrder(cart);
};
return <button onClick={onClick}>Pay</button>;
}When NOT to apply this pattern:
- Glue code whose entire job IS framework integration — an error boundary, a route layout, a server action wrapper.
- Small apps where the indirection costs more than it saves — there's no migration coming.
- When the framework primitive IS the right abstraction — a hook that wraps
useStateto add ergonomics SHOULD be a hook, not a pure function pretending otherwise.
Why this matters: Pure cores at the heart and framework-aware shells at the edges is the same separation that makes commands and queries, DTOs and domain types, testable functions and rendering — all easier to change independently.
Reference: Clean Code, Chapter 8: Boundaries, Clean Architecture — Robert C. Martin
Write Learning Tests for Third-Party Behavior
Before integrating an unfamiliar library, write small focused tests that exercise the parts you'll actually use. They serve double duty: they're the cheapest way to understand the library's contract, and they become regression alarms when a "minor" version bump quietly changes behavior. The cost is small; the cost of debugging a silent semantic change in production is large.
Incorrect (read the docs, integrate 200 lines, hope):
// 200 lines of TanStack Query integration sprinkled through 15 files.
// When v6 quietly changes `keepPreviousData` semantics in a minor version,
// pagination flickers in production and no test catches it.
function OrdersPage() {
const { data, isPlaceholderData } = useQuery({
queryKey: ['orders', page],
queryFn: () => fetchOrders(page),
placeholderData: keepPreviousData, // exact behavior?
});
// ...lots of feature code...
}Correct (a tiny test file documents and guards your assumptions):
// useQuery.learning.test.ts — exercises the 5 patterns we actually use.
// Library upgrade either keeps these green or fails noisily in CI.
describe('TanStack Query: behaviors we depend on', () => {
it('keepPreviousData returns prior page while new page loads', async () => { /* ... */ });
it('staleTime: 0 refetches on every mount', async () => { /* ... */ });
it('error state is reset when queryKey changes', async () => { /* ... */ });
it('dependent queries wait for enabled', async () => { /* ... */ });
it('optimistic update is rolled back on mutation error', async () => { /* ... */ });
});When NOT to apply this pattern:
- Mature, stable APIs with no realistic version risk —
lodash.pick,date-fns.format. The test would never fail. - Single-use integrations you'll remove next sprint — a one-off CSV export library. Pay the cost where it pays back.
- When the library's own test suite already demonstrates the patterns you depend on — link to those instead of duplicating.
Why this matters: Tests pinned to library behavior turn invisible breaking changes into loud CI failures — the same "shift bugs left" principle as making illegal states unrepresentable.
Reference: Clean Code, Chapter 8: Boundaries (Learning Tests), Working Effectively with Legacy Code — Michael Feathers
Type Assertions Belong Only at Boundaries
as T tells the compiler "trust me" and disables the very check you wanted. Sprinkled through business logic, assertions silently propagate untyped values until they explode at runtime far from the source. Confined to a single boundary — where a runtime check verifies what the compiler can't — they acknowledge exactly where trust enters the system, and the downstream code stays honestly typed.
Incorrect (assertions scattered through the codebase):
// Each `as` is a lie the compiler accepts. One bad payload, runtime crash anywhere.
async function loadOrder(json: unknown): Promise<number> {
const order = json as Order;
const id = order.userId as UserId;
const total = order.total as number;
return total * 1.1;
}Correct (parse once at the edge; downstream stays honest):
// Runtime check at the boundary; assertion happens inside the parser.
// Downstream code uses Order without any `as`.
import { z } from 'zod';
const OrderSchema = z.object({
userId: z.string().regex(/^usr_/),
total: z.number().nonnegative(),
});
async function loadOrder(json: unknown): Promise<number> {
const result = OrderSchema.safeParse(json);
if (!result.success) throw new BoundaryError('invalid order payload');
const order = result.data; // honestly typed Order
return order.total * 1.1;
}When NOT to apply this pattern:
- Types that can't be checked at runtime — DOM event types from listeners (
e.target as HTMLInputElement) are sometimes the only available expression. - Bridging a slightly-too-strict typed library to your domain type with a documented one-line assertion — better than refactoring the world.
- Test fixtures where producing a mock shape is the entire point — full parsing in tests is ceremony.
Why this matters: Assertions at boundaries (verified) plus honest types everywhere else is the same trust-once-then-rely shape as DTO/domain translation and branded-type factories.
Reference: Clean Code, Chapter 8: Boundaries, Parse, Don't Validate — Alexis King
Wrap Third-Party Hooks in Custom Hooks
When 25 components each call useSession() from next-auth and reach into session.user.id, every library upgrade or auth-strategy change forces a 25-file rewrite. A single custom hook — useCurrentUser() — owns the shape and returns a domain-typed result. The library swap becomes a one-file change; downstream code doesn't notice.
Incorrect (library shape leaks into every consumer):
// 25 components do this. Migrate from next-auth to Clerk?
// Migrate to JWT? Every call site breaks.
import { useSession } from 'next-auth/react';
function OrderHistory() {
const { data: session, status } = useSession();
if (status === 'loading') return <Spinner />;
if (!session?.user?.id) return <SignInPrompt />;
return <OrdersList userId={session.user.id} />;
}Correct (one wrapper, domain-typed; consumers don't import the library):
// useCurrentUser is the only file that imports next-auth.
// Domain consumers get a typed UserId and don't care about the provider.
function useCurrentUser(): { id: UserId; email: string } | null {
const { data } = useSession();
if (!data?.user?.id || !data.user.email) return null;
return { id: data.user.id as UserId, email: data.user.email };
}
function OrderHistory() {
const user = useCurrentUser();
if (user === null) return <SignInPrompt />;
return <OrdersList userId={user.id} />;
}When NOT to apply this pattern:
- Prototypes and spikes — wrapping before you know what shape you want is premature abstraction.
- Pure renames with no abstraction value —
const useMyQuery = useQueryadds an import indirection without changing anything. - Libraries that ARE already the domain abstraction —
useNavigatefrom react-router oruseParamsare thin and stable enough that re-wrapping them adds noise.
Why this matters: Wrapping turns a wide change surface (every consumer) into a narrow one (one wrapper) — the same locality-of-change principle that motivates DTO/domain separation.
Reference: Clean Code, Chapter 8: Boundaries, React Docs: Reusing Logic with Custom Hooks
Avoid Higher-Order Component Stacks
withAuth(withTheme(withTracking(withTranslation(Component)))) was the React 16 pattern for sharing cross-cutting behavior. It's nearly always worse than hooks: prop injection is opaque, DevTools shows stacks of Connect(Connect(Connect(…))), and TypeScript inference for the composed props degrades fast. In React 19, hooks are the right answer almost every time.
Incorrect (HOC stack injects invisible props):
// What props does CheckoutPage actually receive? Read four HOC signatures to find out.
// Type errors point at the outer wrapper, not the real cause.
export default withAuth(
withTracking(
withTheme(
withTranslation(CheckoutPage)
)
)
);Correct (hooks make dependencies visible and typed):
// Dependencies are right at the top of the component, typed, debuggable.
export default function CheckoutPage() {
const user = useAuth();
const track = useTracking();
const theme = useTheme();
const { t } = useTranslation();
// ...use them directly...
return <main className={theme.background}>{t('checkout.title')}</main>;
}When NOT to apply this pattern:
- HOCs you don't own —
Sentry.withErrorBoundary,withAuthenticationRequiredfrom Auth0 — wrap them in one thin component or hook in a single location, don't try to rewrite them. - HOCs that genuinely transform rendering, not just inject data — a
withSuspense(Component, fallback)that wraps in<Suspense>is acceptable, though<Suspense>composition is usually clearer. - Large legacy codebases — converge gradually; a half-migrated codebase mixing HOCs and hooks is harder to read than either pure form.
Why this matters: Visible, typed dependencies at the top of a component beat invisible prop injection from a stack of wrappers — the same readability principle as command-query separation and intention-revealing names.
Reference: Clean Code, Chapter 10: Classes (substituted: Composition), React Docs: Reusing Logic with Custom Hooks
Compose with Children Over Configuration Props
A component with 15 boolean and slot props quickly becomes a god component that has to know every possible variation. Accepting children (or named-slot children, the compound-component pattern) inverts the relationship — the caller composes the variation, and the component just provides structure and behavior. New use cases stop adding props.
Incorrect (props explosion; every new variant adds a prop):
// Card knows about titles, subtitles, images, actions, footers, dismiss.
// The next variant — a badge in the header — adds a sixteenth prop.
type CardProps = {
title: string;
subtitle?: string;
imageUrl?: string;
actions?: ReactNode[];
footer?: ReactNode;
dismissible?: boolean;
bordered?: boolean;
elevation?: 0 | 1 | 2 | 3;
};
<Card
title="Order #123"
subtitle="Shipped"
imageUrl="/box.png"
actions={[<button key="cancel">Cancel</button>]}
footer={<small>2 items</small>}
dismissible
elevation={2}
/>Correct (compound components; caller composes structure):
// Card provides structure; the call site composes variation.
// Adding a badge needs no new prop on Card.
<Card elevation={2}>
<Card.Header>
<Card.Image src="/box.png" />
<Card.Title>Order #123</Card.Title>
<Card.Subtitle>Shipped</Card.Subtitle>
</Card.Header>
<Card.Actions>
<button>Cancel</button>
</Card.Actions>
<Card.Footer><small>2 items</small></Card.Footer>
</Card>When NOT to apply this pattern:
- Tightly-constrained design systems where the prop API IS the contract — Button with
variant/size/intentis intentionally closed. - Accessibility-critical components — letting consumers freely compose children into a
Comboboxinvites broken ARIA relationships. - Trivially simple components where children make the call site less readable than a clear prop —
<Tooltip text="Save" />beats<Tooltip><TooltipText>Save</TooltipText></Tooltip>.
Why this matters: Letting callers compose moves variation knowledge out of the component, the same way pure cores move framework knowledge out of business logic — small surfaces, many use cases.
Reference: Clean Code, Chapter 10: Classes (substituted: Composition), React Docs: Passing JSX as children
Use Context for True Dependency Injection, Not Prop Avoidance
React Context is a dependency-injection tool for cross-cutting concerns — auth, theme, locale — that many components legitimately need. Reaching for it to skip two levels of prop passing creates hidden coupling and triggers re-render cascades on every value change. If sibling components need to share state, hoist it into their parent; reserve Context for things that actually are global.
Incorrect (Context used for local state sharing):
// SelectedTab is shared between sibling panels — but they have a common parent.
// This Context now re-renders every consumer on every tab click.
const TabContext = createContext<{ selected: string; setSelected: (s: string) => void } | null>(null);
function Tabs({ children }: { children: ReactNode }) {
const [selected, setSelected] = useState('overview');
return (
<TabContext value={{ selected, setSelected }}>{children}</TabContext>
);
}
function TabPanel({ name, children }: { name: string; children: ReactNode }) {
const ctx = useContext(TabContext)!;
return ctx.selected === name ? <div>{children}</div> : null;
}Correct (state lives in the common parent that needs it):
// Tabs is the natural owner of the selected state; no Context needed.
// Re-renders are scoped to Tabs and its direct children.
function Tabs({ panels }: { panels: { name: string; content: ReactNode }[] }) {
const [selected, setSelected] = useState(panels[0]?.name);
return (
<>
<TabList names={panels.map(p => p.name)} selected={selected} onSelect={setSelected} />
{panels.map(p =>
p.name === selected ? <div key={p.name}>{p.content}</div> : null
)}
</>
);
}
// Reserve Context for AuthContext, ThemeContext, LocaleContext — things truly global.When NOT to apply this pattern:
- State libraries (Zustand, Jotai, Redux Toolkit) handle cross-cutting high-frequency state better than Context — when the state IS global AND updates often, reach for one of those instead of Context.
- Deep trees (5+ levels) where lifting state and threading props would create more coupling than a tightly-scoped Context.
- Compound-component patterns where Context is the implementation detail that lets
<Tabs.Panel>find its<Tabs>parent — that's a legitimate, local use of Context.
Why this matters: Context is a dependency-injection mechanism, not a prop bypass — using it correctly preserves the same change-locality and re-render-predictability that immutability and small components give you.
Reference: Clean Code, Chapter 10: Classes (substituted: Composition), React Docs: Passing Data Deeply with Context
Prefer Hooks Over Render Props for Logic Reuse
Render props were React 16's answer to sharing stateful logic. Hooks (since 16.8) do the same job with less indentation, no "wrapper hell," better TypeScript inference, and trivial composition with other hooks. Keep render props for the case they're actually for — injecting markup, not just logic.
Incorrect (render prop for what should be a hook):
// Verbose; nesting two providers needs two layers of render-prop indentation;
// inference for the inner function's params is fiddly.
function MousePosition({ children }: { children: (p: { x: number; y: number }) => ReactNode }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return <>{children(pos)}</>;
}
function MouseIndicator() {
return <MousePosition>{({ x, y }) => <div>{x},{y}</div>}</MousePosition>;
}Correct (hook composes cleanly with other hooks):
// Use the value alongside any other hook; no extra indentation.
function useMousePosition(): { x: number; y: number } {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return pos;
}
function MouseIndicator() {
const { x, y } = useMousePosition();
return <div>{x},{y}</div>;
}When NOT to apply this pattern:
- Components that genuinely need to wrap children in markup — a
<Popover>that renders an overlay around its consumer; a<DragOverlay>from dnd-kit. Render props or compound components are the right tool. - Library APIs where render props are the established convention — many chart libraries (Recharts, Visx) use them for cell-level customization; follow their grain.
- Legacy code where converting a render prop to a hook is a wide-spread, low-payoff change.
Why this matters: Picking hooks for logic reuse and composition for markup reuse keeps each tool used for what it's actually good at — the same shape as command-query separation.
Reference: Clean Code, Chapter 10: Classes (substituted: Composition), React Docs: Reusing Logic with Custom Hooks
Separate Setup (Effects, Subscriptions) from Rendering
A component's render is for describing UI given the current state — it must be pure. Effects (fetches, subscriptions, side effects) belong in setup: useEffect, useQuery, the new use(...) Promise consumer, or in a Server Component. Mixing them — firing fetch() mid-render, mutating refs while rendering — violates React's contract and causes invisible bugs: extra requests, race conditions, missing re-renders.
Incorrect (side effect fires inside render):
// fetch() runs on every render; the returned promise is never awaited correctly;
// React strict mode double-renders amplify the bug.
function OrderList() {
const data = fetch('/api/orders').then(r => r.json());
return <ul>{/* what is `data` even? a Promise, not a list */}</ul>;
}Correct (setup belongs in a hook; render reads the result):
// `use` (React 19) consumes a Promise during render with Suspense integration.
// Alternatives: useQuery, useEffect+useState, or fetch in a Server Component.
function OrderList() {
const orders = use(fetchOrders());
return (
<ul>
{orders.map(o => <li key={o.id}>{o.label}</li>)}
</ul>
);
}
// Or in a Server Component, fetch IS the render-time data:
async function OrderListServer() {
const orders = await fetchOrders();
return <ul>{orders.map(o => <li key={o.id}>{o.label}</li>)}</ul>;
}When NOT to apply this pattern:
- Server Components and Route Handlers — the framework's contract IS to do data fetching during render. That's not a violation; that's the seam.
- Non-React code — small scripts and one-shot builders mixing construction and use are fine. This rule is about preserving React's purity contract specifically.
- Genuinely synchronous computed values that look like "setup" but aren't — deriving a memoized selector inside render is fine; deriving via
useMemoonly matters for perf.
Why this matters: Keeping render pure aligns with React's mental model and prevents an entire family of "why does this re-render forever?" bugs — the same separation principle as commands vs queries.
Reference: Clean Code, Chapter 10: Classes (substituted: Composition), React Docs: Keeping Components Pure
Keep Components Small and Cohesive
The same "keep functions small" principle applies to components, with a twist: cohesion matters as much as size. A 300-line <CheckoutPage> is too big — but splitting it into 30 ten-line components scattered across the codebase is worse. Aim for components that fit on a screen AND keep related state, JSX, and effects together; co-locate splits in a folder when they aren't reused.
Incorrect (one 300-line component doing four jobs):
// Form, payment widget, error banner, confirmation modal — all inline.
// Three engineers can't work on it at once; nothing is independently testable.
function CheckoutPage({ cart }: { cart: Cart }) {
const [shipping, setShipping] = useState<Shipping>(emptyShipping);
const [payment, setPayment] = useState<Payment>(emptyPayment);
const [error, setError] = useState<string | null>(null);
const [confirmed, setConfirmed] = useState(false);
// ...250 more lines of form, payment, modal, error JSX...
return <div>{/* huge tree */}</div>;
}Correct (small cohesive pieces, co-located):
// checkout/CheckoutPage.tsx orchestrates; pieces live next to it.
function CheckoutPage({ cart }: { cart: Cart }) {
const [error, setError] = useState<string | null>(null);
const [confirmed, setConfirmed] = useState(false);
return (
<CheckoutLayout>
<CheckoutForm cart={cart} onError={setError} onSuccess={() => setConfirmed(true)} />
<PaymentMethodSelector />
{error && <CheckoutErrorBanner message={error} />}
{confirmed && <ConfirmationModal />}
</CheckoutLayout>
);
}
// checkout/CheckoutForm.tsx, PaymentMethodSelector.tsx, etc.When NOT to apply this pattern:
- Components used in one place and already under ~50 lines — extracting splits cohesive code without payoff.
- Design tokens / theme primitives that are small by nature (
<Spacer />,<Stack />) — further splitting is just noise. - Performance-sensitive trees where extra component boundaries add re-render overhead; use the React compiler or
memostrategically before splitting.
Why this matters: Components that fit on a screen and stay cohesive are easier to change, test, and parallelize across a team — the same locality principle as small functions.
Reference: Clean Code, Chapter 3: Functions (Small!), React Docs: Thinking in React
Brand Types to Make Domain Distinctions Compile-Checked
type UserId = string and type OrderId = string are interchangeable to TypeScript — pass one where the other is expected and the compiler shrugs. Branding (intersecting with a unique tag) makes them nominally distinct, so swaps become compile errors. A single factory function becomes the only legitimate way to construct one, which centralizes validation at the boundary.
Incorrect (compiler can't catch argument swap):
// Caller can swap arguments — both are strings. Silent runtime bug.
type UserId = string;
type OrderId = string;
function getOrder(userId: UserId, orderId: OrderId): Promise<Order> {
return fetch(`/users/${userId}/orders/${orderId}`).then(r => r.json());
}
// Oops — swapped. Compiles fine, 404s at runtime (or worse, wrong order).
getOrder(order.id, user.id);Correct (swap is a compile error):
// Same call site shape; compiler now refuses the swap.
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
const parseUserId = (s: string): UserId | null =>
/^usr_[a-z0-9]+$/.test(s) ? (s as UserId) : null;
const parseOrderId = (s: string): OrderId | null =>
/^ord_[a-z0-9]+$/.test(s) ? (s as OrderId) : null;
function getOrder(userId: UserId, orderId: OrderId): Promise<Order> {
return fetch(`/users/${userId}/orders/${orderId}`).then(r => r.json());
}
getOrder(order.id, user.id); // Error: 'OrderId' is not assignable to 'UserId'When NOT to apply this pattern:
- Throwaway scripts and small apps where the ceremony of factories and parsers exceeds the value of safety.
- Values that are genuinely just strings — a display name, a free-text comment — branding adds noise without invariant.
- Teams not bought in — branded types add friction everywhere they're used; one holdout doing
as UserIddefeats the purpose.
Why this matters: Naming with the compiler's help turns a documentation convention ("the first arg is a user id") into an enforced invariant — the same shift as discriminated unions, applied to scalars.
Reference: Clean Code, Chapter 2: Meaningful Names, Parse, Don't Validate — Alexis King
Prop Drilling Often Smells Like Demeter Violation
The Law of Demeter says "don't talk to strangers": a function should only call methods of objects it directly knows. When <Avatar> four levels deep takes the whole user object just to read user.imageUrl, it has full knowledge of User's structure — and every refactor of User risks breaking Avatar. Pass only what's needed, or hoist truly cross-cutting state into Context.
Incorrect (each level handles the full `user` it doesn't need):
// Every intermediate component is now coupled to the User type
// even though only Avatar reads a single field.
function App({ user }: { user: User }) { return <Page user={user} />; }
function Page({ user }: { user: User }) { return <Sidebar user={user} />; }
function Sidebar({ user }: { user: User }) { return <Avatar user={user} />; }
function Avatar({ user }: { user: User }) {
return <img src={user.profile.imageUrl} alt={user.name} />;
}Correct (pass only what's used; or use Context for true cross-cutting state):
// Intermediate components no longer know User's shape; only Avatar does.
function App({ user }: { user: User }) {
return <Page imageUrl={user.profile.imageUrl} name={user.name} />;
}
function Page(props: AvatarProps) { return <Sidebar {...props} />; }
function Sidebar(props: AvatarProps) { return <Avatar {...props} />; }
type AvatarProps = { imageUrl: string; name: string };
function Avatar({ imageUrl, name }: AvatarProps) {
return <img src={imageUrl} alt={name} />;
}
// Alternative: put current user in AuthContext and let Avatar read it directly.When NOT to apply this pattern:
- Shallow trees (one or two levels) — destructuring at the top adds noise without paying off.
- The prop is genuinely used at every intermediate level for other reasons (e.g., each level renders something user-specific).
- Reaching for Context would couple a dozen unrelated components to one store — sometimes explicit prop passing is the more honest dependency.
Why this matters: Reducing what each component knows about types it doesn't use makes refactors cheaper and re-render impact smaller — the same change-locality principle as DTO separation.
Reference: Clean Code, Chapter 6: Objects and Data Structures (Law of Demeter), Before You memo() — Dan Abramov
Use Discriminated Unions Over Boolean Flags
A type with N independent boolean flags has 2^N possible states, most of which are illegal — isLoading && data && error should never happen, but a flag-based type says it can. Discriminated unions make only the LEGAL states representable, so the compiler enforces what comments used to ask the reader to remember. Bugs that used to require a unit test become unrepresentable.
Incorrect (flag soup admits impossible combinations):
// Reader has to mentally exclude isLoading && data, error && data, etc.
type OrderState = {
status: string;
isLoading: boolean;
error: string | null;
data: Order | null;
};
function OrderView({ state }: { state: OrderState }) {
if (state.isLoading) return <Spinner />;
if (state.error) return <ErrorBanner message={state.error} />;
// Compiler thinks state.data could be null here — must defensive-check
if (state.data) return <OrderDetails order={state.data} />;
return null;
}Correct (only legal states are representable):
// Reader doesn't have to defend against impossible combinations.
type OrderState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Order }
| { status: 'error'; error: string };
function OrderView({ state }: { state: OrderState }) {
switch (state.status) {
case 'loading': return <Spinner />;
case 'error': return <ErrorBanner message={state.error} />;
case 'success': return <OrderDetails order={state.data} />; // data is non-null here
case 'idle': return null;
}
}When NOT to apply this pattern:
- Standalone 2-state booleans with no related fields —
isPublic: booleanon aPostdoesn't need a union. - Very wide unions (>6 variants) where readability suffers more than it gains — reach for a state-machine library (XState) instead of hand-rolled tags.
- Legacy code whose flag-shaped state is part of a published API contract — refactor behind a translation layer rather than breaking consumers.
Why this matters: Making illegal states unrepresentable shifts a class of runtime bugs to compile time — the type IS the invariant.
Reference: Clean Code, Chapter 6: Objects and Data Structures, Making Impossible States Impossible — Richard Feldman
Separate DTOs from Domain Types
The shape your API returns rarely matches the shape your domain wants — snake_case keys, string-encoded dates, nullable fields that your UI treats as defaulted. Using the API shape directly couples every component to the wire format; a translation step at the boundary lets the domain stay clean and the change surface stay small when the API moves.
Incorrect (wire format leaks into every consumer):
// Every component now knows about user_id, created_at strings, and nullable bios.
// API renames user_id -> id? Every consumer breaks.
type User = {
user_id: string;
created_at: string;
profile_data: { bio: string | null };
};
function UserHeader({ user }: { user: User }) {
const joined = new Date(user.created_at).toLocaleDateString();
return <h1>{user.profile_data.bio ?? 'No bio'} — joined {joined}</h1>;
}Correct (translate at the edge, keep the domain clean):
// One translator absorbs API quirks; consumers see idiomatic domain types.
type UserDTO = {
user_id: string;
created_at: string;
profile_data: { bio: string | null };
};
type User = {
id: string;
createdAt: Date;
bio: string;
};
const toUser = (dto: UserDTO): User => ({
id: dto.user_id,
createdAt: new Date(dto.created_at),
bio: dto.profile_data.bio ?? 'No bio',
});
function UserHeader({ user }: { user: User }) {
return <h1>{user.bio} — joined {user.createdAt.toLocaleDateString()}</h1>;
}When NOT to apply this pattern:
- API and domain genuinely have the same shape — internal tools, admin UIs that are thin wrappers over CRUD endpoints.
- Generated SDK clients (OpenAPI, tRPC, GraphQL codegen) — the generated types already provide a stable boundary; adding another translation layer duplicates effort.
- Tiny apps where the cost of maintaining two parallel type hierarchies exceeds the cost of API coupling.
Why this matters: A translation boundary turns "change ripples through the codebase" into "change touches one mapper" — the same locality-of-change principle behind wrapping third-party hooks.
Reference: Clean Code, Chapter 8: Boundaries, Anti-Corruption Layer — Domain-Driven Design
Mark Read-Only Data Readonly
Most data in TS+React is read-only by intent — props, hook return values, store snapshots. Marking it readonly documents intent AND makes accidental in-place mutation a compile error. This matters more in React than elsewhere because React diffs by reference: a mutated array keeps the same reference, so the UI silently fails to update.
Incorrect (props can be mutated, breaking React's reference equality):
// CartSummary sorts in place; parent's state object is now mutated and
// React won't re-render dependent components reliably.
type CheckoutProps = {
items: CartItem[];
total: number;
};
function CartSummary({ items, total }: CheckoutProps) {
items.sort((a, b) => a.price - b.price); // silently mutates parent state
return <CartTable items={items} total={total} />;
}Correct (mutation becomes a compile error):
// .sort() on a readonly array is a type error — caller is forced to copy first.
type CheckoutProps = {
readonly items: readonly CartItem[];
readonly total: number;
};
function CartSummary({ items, total }: CheckoutProps) {
const sorted = [...items].sort((a, b) => a.price - b.price); // explicit copy
return <CartTable items={sorted} total={total} />;
}When NOT to apply this pattern:
- Hot paths where
readonlywrapper allocations measurably hurt performance — rare in app code, more relevant in tight loops in libraries. - Internal helpers where mutation IS the operation — a builder pattern, a draft state, the inside of an Immer producer.
- Interop with libraries that take mutable types (older Redux Toolkit
createSlicedraft, some D3 APIs) — readonly there fights the library.
Why this matters: Immutability by default removes a class of "why didn't it re-render?" bugs and makes data flow easier to reason about — the same principle as preferring pure functions.
Reference: Clean Code, Chapter 6: Objects and Data Structures, TypeScript Handbook: readonly
Beware Structural Typing Aliasing
TypeScript uses structural (duck) typing — two types with the same shape are interchangeable even when they mean different things. Most of the time this is great ergonomics; occasionally it causes silent bugs. A function that accepts Point2D will happily take Vector2D because the shape matches, even though adding a position to a velocity is nonsense. When the distinction is load-bearing, brand or tag the types.
Incorrect (semantically distinct types are interchangeable):
// Caller can pass a Vector2D where a Point2D is expected — compiles, makes no sense.
type Point2D = { x: number; y: number };
type Vector2D = { x: number; y: number };
function distance(a: Point2D, b: Point2D): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
const velocity: Vector2D = { x: 3, y: 4 };
const origin: Point2D = { x: 0, y: 0 };
distance(origin, velocity); // compiles; semantically wrongCorrect (tag the types so the compiler refuses the mix-up):
// Same shape, but the brand makes the kinds incompatible.
type Point2D = { readonly __kind: 'Point'; x: number; y: number };
type Vector2D = { readonly __kind: 'Vector'; x: number; y: number };
const point = (x: number, y: number): Point2D => ({ __kind: 'Point', x, y });
const vector = (x: number, y: number): Vector2D => ({ __kind: 'Vector', x, y });
function distance(a: Point2D, b: Point2D): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
distance(point(0, 0), vector(3, 4)); // Error: Vector2D not assignable to Point2DWhen NOT to apply this pattern:
- Most app code — structural typing is a feature, not a bug; ducktyping JSON shapes from APIs is exactly what you want.
- Distinctions that are purely documentary and never matter at runtime — a
CelsiusvsFahrenheitnumber in a UI that always shows the unit label is fine as plainnumber. - Public library APIs where consumers pass their own shaped types — rejecting them on nominal grounds is unfriendly without strong invariant to defend.
Why this matters: Structural typing is great default, but when types mean different things — currencies, units, IDs, coordinates — nominal distinction prevents a category of "looks fine, compiles, wrong" bugs.
Reference: Clean Code, Chapter 6: Objects and Data Structures, Effective TypeScript: Item 4 — Get Comfortable with Structural Typing
Avoid Redundant Comments
A comment that restates what the code already says adds noise and decays — when the code changes, the comment must change too, but rarely does. A wrong comment is worse than no comment: it actively misleads the reader who trusted it. Delete comments that paraphrase their own line.
Incorrect (the comment says what the code says):
function CartSummary({ items }: { items: CartItem[] }) {
// increment quantity by 1
const incrementQuantity = (id: string) => {
setItems((prev) =>
prev.map((item) =>
// if the item id matches, update quantity
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
),
);
};
/** Returns the total price of all items in the cart */
const getTotalPrice = (): number =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return <div>{/* ... */}</div>;
}Correct (code speaks for itself; comments deleted):
function CartSummary({ items }: { items: CartItem[] }) {
const incrementQuantity = (id: string) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, quantity: item.quantity + 1 } : item,
),
);
};
const getTotalPrice = (): number =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return <div>{/* ... */}</div>;
}When NOT to apply this pattern:
- Redundant-LOOKING comments that disambiguate a subtle convention (
// 0-indexed, NOT 1-indexedon anindexparameter;// in cents, not dollarson anamount: number). - JSDoc on public API exports — IDE tooltips show the description at the call site, so even a near-paraphrase has value for consumers.
- Teaching codebases, tutorials, and onboarding examples where the audience is a learner, not a maintainer.
Why this matters: Every line a reader has to read costs attention. Spend that budget on signal, not paraphrase.
Delete Commented-Out Code
Commented-out code is dead weight that every future reader must evaluate: is it safe to restore? Why was it kept? Is it the canonical version or a stale draft? Git history is your archive — it remembers without polluting the working tree. Delete commented-out code; trust source control.
Incorrect (alternate flow preserved as a comment, slowly rotting):
async function checkout(order: Order) {
// const oldFlow = await legacyCheckout(order);
// TODO: remove once Q2 migration is complete
// if (oldFlow.status === 'pending') {
// await retryLegacy(oldFlow);
// }
const result = await processCheckout(order);
// legacy fallback — keep until 2024-09
// if (!result.ok) return legacyCheckout(order);
return result;
}Six months later, no one remembers if the legacy fallback is required, whether legacyCheckout still exists, or which path is authoritative.
Correct (delete it; rely on git for archaeology):
async function checkout(order: Order) {
// Pre-migration checkout flow lives in git history.
// See: git log --oneline -- src/checkout.ts (commit abc123, "Remove legacy checkout")
const result = await processCheckout(order);
return result;
}If the reference to the old commit is itself unnecessary, drop that too — the commit log is sufficient.
When NOT to apply this pattern:
- Temporary local diagnostics while actively debugging — fine to leave in your working copy, but delete before committing.
- Regression test cases deliberately disabled with an explicit reason (
it.skip('reproduces bug #4521 — re-enable when fixed', ...)) — this is documented intent, not commented-out code. - Template / example files in starter kits or documentation where commented lines are configuration hints for the user.
Why this matters: Source control already preserves history. Commented code in the working tree taxes every reader forever for a benefit git log already provides for free.
Reference: Clean Code, Chapter 17: Smells and Heuristics — Commented-Out Code
JSDoc for Public APIs and Non-Obvious Side Effects
TypeScript types describe the shape of a value; JSDoc adds intent, side effects, and constraints that types cannot express. Critically, JSDoc on exported symbols renders in consumer IDEs at the call site — so for library functions, custom hooks, and shared utilities, JSDoc is documentation that travels with the symbol.
Incorrect (consumer must read the implementation to discover side effects):
// Exported hook with no JSDoc — call sites have no warning that this
// subscribes to storage events and re-renders the component on cross-tab
// activity. Consumer learns this only by spelunking the source.
export function useAuthSession() {
const [session, setSession] = useState<Session | null>(() => readSession());
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === 'auth') setSession(readSession());
};
const onFocus = () => setSession(readSession());
window.addEventListener('storage', onStorage);
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('storage', onStorage);
window.removeEventListener('focus', onFocus);
};
}, []);
return session;
}Correct (intent and side effects visible on hover):
/**
* Reads the current auth session and keeps it in sync across tabs.
*
* Side effects:
* - Subscribes to `window` `storage` events (cross-tab logout/login).
* - Subscribes to `window` `focus` to refresh on tab return.
*
* Re-renders the calling component whenever the session changes.
*
* @returns The current session, or `null` if logged out.
*/
export function useAuthSession() {
const [session, setSession] = useState<Session | null>(() => readSession());
// ... same implementation ...
return session;
}When NOT to apply this pattern:
- Internal helpers whose only call sites live in the same module — the implementation IS the documentation, and JSDoc just duplicates it.
- Trivial signatures where the name and types are self-explanatory (
function add(a: number, b: number): number). - Package-private modules not exported from the package entry point — consumers can't reach them, so the IDE-tooltip benefit doesn't apply.
Why this matters: Consumers form their mental model from what they see at the call site. JSDoc is the only documentation that travels there.
Reference: Clean Code, Chapter 4: Comments, TSDoc specification
Use satisfies for Inferred-But-Checked Values
as Type is an assertion ("trust me, compiler"); : Type widens the value to the annotation and discards the precise inferred literals; satisfies Type (TS 4.9+) keeps the narrow inferred type AND checks it conforms to the contract. For configuration objects, route maps, and constant lookups, satisfies is almost always what you want.
Incorrect (widening loses literals; assertion bypasses the check):
// Option A: type annotation — ROUTES.checkout is widened to `string`,
// so we can't use it as a discriminant later.
const ROUTES: Record<string, string> = {
home: '/',
checkout: '/checkout',
orders: '/orders',
};
// Option B: as-assertion — typo isn't caught because we asserted blindly.
const STATUSES = {
pending: 'pending',
shippped: 'shippped', // typo compiles fine
} as Record<string, string>;Correct (narrow inferred types AND shape verified):
const ROUTES = {
home: '/',
checkout: '/checkout',
orders: '/orders',
} satisfies Record<string, `/${string}`>;
// ROUTES.checkout has type '/checkout' (not string) — usable as a literal.
const STATUSES = {
pending: 'pending',
shippped: 'shippped', // compile error: not a valid OrderStatus
} satisfies Record<string, 'pending' | 'shipped' | 'delivered'>;When NOT to apply this pattern:
- When you genuinely want widening — e.g., a public API constant typed as
stringbecause consumers legitimately compare against arbitrary strings. - Generic constraints where
extendsis the right tool (function get<K extends keyof T>(...)—satisfiesdoesn't apply). - Mutable values:
satisfiesdoes not make a valuereadonly; pair withas constif immutability is the actual goal.
Why this matters: Precision in types pays off at every downstream use site — autocomplete, discriminants, exhaustive switches — without sacrificing the safety check the annotation gave you.
Reference: TypeScript 4.9 release notes: `satisfies`, Matt Pocock on `satisfies`
Prefer Types Over Comments
TypeScript types are checked by the compiler and renamed automatically by refactor tools; comments drift silently the moment code changes. Whenever a comment describes a constraint on a value (allowed strings, required shape, exclusive states), encode it as a type so the invariant cannot rot.
Incorrect (comment carries the invariant, compiler doesn't):
// status must be one of: 'idle', 'loading', 'success', 'error'
// caller is responsible for not passing anything else
function setOrderStatus(orderId: string, status: string) {
// Reader has to trust the comment AND remember to update it
// when the team adds a 'cancelled' state next sprint.
updateOrder(orderId, { status });
}
setOrderStatus('order_42', 'loadign'); // typo compiles fineCorrect (the comment IS the type):
type OrderStatus = 'idle' | 'loading' | 'success' | 'error';
function setOrderStatus(orderId: string, status: OrderStatus) {
// Reader gets autocomplete; rename of a status renames every call site;
// typos fail at compile time, not in production at 3am.
updateOrder(orderId, { status });
}
setOrderStatus('order_42', 'loadign'); // compile errorWhen NOT to apply this pattern:
- WHY-level rationale a type can never express ("we use
Maphere instead of a plain object because keys can besymbols for tenant isolation"). - Legal / license headers and copyright notices required by policy.
- External constraints the compiler can't verify ("Stripe API rate-limits this endpoint at 100 RPS — batch above that").
Why this matters: A type-checked invariant survives refactors; a commented invariant survives only until the next merge.
Reference: Clean Code, Chapter 4: Comments, Matt Pocock on `as const` and literal types
Apply the Four Rules of Simple Design in Order
Kent Beck's four rules — (1) Passes tests, (2) Reveals intent, (3) No duplication, (4) Fewest elements — are a priority order, not a checklist. (1) is non-negotiable. (4) is fine only after (1)-(3) are already true. Skipping the order produces "elegant" code that's broken, or "DRY" code that nobody understands.
Incorrect (collapsing duplication before intent is clear):
// Step 1: tests pass.
// Step 2: SKIPPED — names like `handle`, `process` reveal nothing.
// Step 3: extracted because the call sites looked similar.
// Result: a clever abstraction nobody can read or extend.
function handle<T>(items: T[], k: keyof T, p: (x: T) => boolean): T[] {
return items.filter(p).sort((a, b) => (a[k] > b[k] ? 1 : -1));
}
const out = handle(orders, 'total', (o) => o.status === 'paid');Correct (reveal intent first, deduplicate only if concepts truly match):
// Rule 2 (intent) before Rule 3 (DRY).
// If a second use site appears with genuinely the same concept,
// THEN extract. Until then, this reads top-to-bottom.
function listPaidOrdersByTotal(orders: Order[]): Order[] {
return orders
.filter((order) => order.status === 'paid')
.sort((a, b) => a.total - b.total);
}
const paidOrders = listPaidOrdersByTotal(orders);When NOT to apply this pattern:
- Framework-shaped code where "fewest elements" competes with library conventions (Next.js route segments, React Server Component boundaries) — follow the framework's shape.
- Design-system primitives where the elements ARE the public API — splitting a
<Stack>into<Stack.Item>is the contract, not over-decomposition. - Prototypes and spikes — simple design is unfinished by definition; optimize when the design stabilizes.
Why this matters: The rules are ordered because they trade off. A premature collapse to "fewest elements" usually destroys "reveals intent" and breaks "passes tests" with edge cases the original code handled implicitly.
Reference: Kent Beck — The Four Rules of Simple Design, Clean Code, Chapter 12: Emergence
Resist Premature Abstraction
The rule of three: duplicate twice before abstracting. The wrong abstraction couples unrelated concepts and forces them to evolve together. As Sandi Metz put it, "duplication is far cheaper than the wrong abstraction" — un-abstracting a bad abstraction costs more than living with copy-paste.
Incorrect (abstracting on the second occurrence):
// After seeing <OrdersList> and <InvoicesList> both call fetch(),
// you extract useResource. Then:
// - The next consumer needs cache invalidation -> add `invalidateOn` param.
// - The next needs polling -> add `pollIntervalMs`.
// - The next needs WebSocket updates -> add `subscribe`.
// useResource becomes 200 lines of optional params; every change risks
// breaking the four unrelated callers.
function useResource<T>(
url: string,
opts?: {
invalidateOn?: string[];
pollIntervalMs?: number;
subscribe?: boolean;
transform?: (raw: unknown) => T;
}
): { data?: T; error?: Error; isLoading: boolean } {
// ...sprawling implementation
}Correct (concrete first; abstract when the third caller proves the shape):
// Each list owns its small fetch logic. When five lists exist and a
// clear shared shape is visible, extract THAT specific shape — usually
// smaller than the speculative one (e.g., just a fetcher util).
function useOrders(): { orders: Order[]; isLoading: boolean } {
const [orders, setOrders] = useState<Order[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch('/api/orders')
.then((r) => r.json())
.then(setOrders)
.finally(() => setIsLoading(false));
}, []);
return { orders, isLoading };
}When NOT to apply this pattern:
- When a well-known, battle-tested abstraction already exists — use TanStack Query's
useQuery; don't reinvent it. - When duplication crosses team or service boundaries that will diverge — duplicating IS the right call, and the abstraction would create the wrong coupling.
- When the design space is clearly exhausted by the first 2-3 cases (e.g., three near-identical admin CRUD pages with a known fourth coming) — abstract earlier with eyes open.
Why this matters: A bad abstraction is harder to remove than duplication is to live with. Wait for the shape to reveal itself before naming it.
Reference: Sandi Metz — The Wrong Abstraction, Rich Hickey — Simple Made Easy
Maximize Expressiveness — Code as Communication
Code is communication first, instruction to the machine second. Every name, every type, every structural choice either reveals or obscures intent. The reader is the customer; the bytecode is a side effect. The cost of an unclear name is paid by every future reader, including yourself in six months.
Incorrect (abbreviated names, opaque string literals, vague verbs):
function process(users: User[]) {
// What status is 'a'? What does processStuff do? Why these users?
const u = users.filter((x) => x.s === 'a');
return processStuff(u);
}Correct (every name carries meaning):
function sendMonthlyNewsletter(users: User[]) {
// status, 'active', sendMonthlyNewsletter — each name reveals what and why.
const activeUsers = users.filter((user) => user.status === 'active');
return enqueueNewsletterDelivery(activeUsers);
}When NOT to apply this pattern:
- Tight inner loops and hot paths where verbose locals genuinely hurt readability for the cycle (rare in app code; common in numerics).
- Conventional cryptic forms — matrix math with
M,v, indicesi/j,x/y/zfor vectors — where the convention itself IS the readable form. - Minified production bundles — they're not human-read by definition; the source is what matters.
Why this matters: A program's lifetime cost is dominated by reading, not writing. Optimizing names for the reader is the highest-leverage cleanup you can do.
Reference: Clean Code, Chapter 2: Meaningful Names, Rich Hickey — Simple Made Easy
Avoid Premature Type Generics
A function fetchItems<T extends BaseEntity, K extends keyof T, R = T[K]> that's called once with fetchItems<Order>(...) is a few bytes of value buried under generic gymnastics. Add type parameters when you have two or more concrete callers with different types — not because the function "might be reusable."
Incorrect (speculative generics with one caller):
// Used only by /api/orders. The TBody, TResponse, TError parameters
// are never instantiated with anything else. Readers must mentally
// substitute concrete types every time they read the signature.
async function makeRequest<
TBody extends Record<string, unknown>,
TResponse,
TError = Error,
>(url: string, body: TBody): Promise<TResponse> {
const res = await fetch(url, { method: 'POST', body: JSON.stringify(body) });
if (!res.ok) throw new Error('failed') as TError;
return (await res.json()) as TResponse;
}
const order = await makeRequest<CreateOrderInput, Order>('/api/orders', input);Correct (concrete now; generalize when a second caller arrives):
// Boring. Obvious. Reads like its purpose.
// When /api/refunds or /api/shipments needs the same shape AND
// the shape is genuinely the same, extract a shared helper THEN.
async function createOrder(input: CreateOrderInput): Promise<Order> {
const res = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(input),
});
if (!res.ok) throw new Error('createOrder failed');
return res.json();
}
const order = await createOrder(input);When NOT to apply this pattern:
- Genuine library code with multiple external consumers — the generic IS the API (think TanStack Query's
useQuery<TData, TError>). - Utility types in
@types/*packages or shared kits, where parameterization is the entire point. - Cases where the only alternative is
any— a generic that preserves the caller's type is doing real work and should stay.
Why this matters: Generics are a cost paid by every reader and a tax on every refactor. They should be earned by real-world reuse, not anticipated by imagination.
Reference: Clean Code, Chapter 17: Smells and Heuristics (G33 — Encapsulate Boundary Conditions), Matt Pocock — Don't reach for generics too soon
Use Early Returns to Flatten Error Paths
Nesting if/else to handle each error condition pushes the actual work deeper into the function and forces the reader to track every brace. Guard clauses — return or throw at the top for each invalid input — leave the body at a single indent level for the happy path, which is what readers are usually looking for.
Incorrect (happy path buried 4 indents deep):
function processPayment(order: Order | null): PaymentReceipt {
if (order) {
if (order.isValid) {
if (order.amount > 0) {
if (order.currency === 'USD') {
// The actual work, four indents in.
const receipt = chargeCard(order);
return receipt;
} else {
throw new Error('unsupported currency');
}
} else {
throw new Error('amount must be positive');
}
} else {
throw new ValidationError('order invalid');
}
} else {
throw new Error('order required');
}
}Correct (guards first, happy path at one indent):
function processPayment(order: Order | null): PaymentReceipt {
if (!order) throw new Error('order required');
if (!order.isValid) throw new ValidationError('order invalid');
if (order.amount <= 0) throw new Error('amount must be positive');
if (order.currency !== 'USD') throw new Error('unsupported currency');
// Happy path at one indent — reader finds it immediately.
const receipt = chargeCard(order);
return receipt;
}When NOT to apply this pattern:
- When cleanup must run for every branch — a
try { ... } finally { release() }is cleaner than scattered early returns that each duplicate the cleanup. - Very short functions (3-4 lines total) where nesting is already legible — flattening adds little.
- Functional pipelines that treat errors as values (
Result,Either) — error handling is in the type, not control flow, so guard clauses don't apply.
Why this matters: The shape of indentation is the shape of the function's logic. Keep the happy path flat.
Reference: Clean Code, Chapter 7: Error Handling, Martin Fowler on Guard Clauses
Use Error Boundaries for Render-Time Failures
React renders are pure functions — a thrown error escapes any surrounding synchronous try/catch because React calls the component, not your code. Error Boundaries catch errors during render, lifecycle, and constructor execution of the components below them and let you show a fallback UI. Without boundaries, a single failure in a leaf component crashes the entire tree.
Incorrect (try/catch in effects can't catch render errors; no fallback):
function OrdersPage() {
// This try/catch CANNOT catch a render error inside <OrderList />.
// If `<OrderList />` throws during render, the whole app unmounts.
useEffect(() => {
try {
// ... unrelated effect work ...
} catch (e) {
console.error(e);
}
}, []);
return (
<div>
<Header />
<OrderList /> {/* one bad row -> white screen for everything */}
<Footer />
</div>
);
}Correct (boundary scoped to the feature; rest of page survives):
import { ErrorBoundary } from 'react-error-boundary';
function OrdersPage() {
return (
<div>
<Header />
<ErrorBoundary
FallbackComponent={OrdersErrorFallback}
onError={(error, info) => reportError(error, { component: info.componentStack })}
>
<OrderList /> {/* a render error here shows OrdersErrorFallback, header + footer stay */}
</ErrorBoundary>
<Footer />
</div>
);
}
function OrdersErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert">
<p>Couldn't load orders: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}When NOT to apply this pattern:
- Leaf components inside a tree that already has an appropriate boundary — adding another boundary just fragments the fallback UX.
- Tiny utility components where a per-component fallback makes no sense (e.g., a
<Tooltip>— let the parent boundary handle it). - Framework-managed pages — Next.js
error.tsxand RemixErrorBoundaryexports already wire boundaries for you; don't duplicate at the route level.
Why this matters: Error Boundaries are React's equivalent of try/catch for the render phase. Without them, your app has no recovery story for component bugs.
Reference: react.dev — Error Boundaries, react-error-boundary
Always Narrow unknown in Catch Blocks
With useUnknownInCatchVariables (TS 4.4+), catch variables are typed unknown — because JavaScript lets you throw anything: strings, numbers, plain objects, even undefined. Reaching for .message or .stack without narrowing risks a TypeError inside your error handler, which masks the original error.
Incorrect (assumes `e` is an `Error`):
async function syncInvoice(invoiceId: string) {
try {
await stripeClient.invoices.retrieve(invoiceId);
} catch (e) {
// If `e` is `'rate_limited'` (a string thrown by some library),
// `e.message` is `undefined` and `.toUpperCase()` crashes the handler.
logger.error(e.message.toUpperCase());
}
}Correct (narrow before use):
async function syncInvoice(invoiceId: string) {
try {
await stripeClient.invoices.retrieve(invoiceId);
} catch (e) {
// Narrow once; safe everywhere below.
const message = e instanceof Error ? e.message : String(e);
logger.error(message);
// For domain-specific handling, narrow on a known class:
if (e instanceof StripeRateLimitError) {
await scheduleRetry(invoiceId, e.retryAfter);
return;
}
throw e;
}
}When NOT to apply this pattern:
- Codebases pre-TS 4.4 or with
useUnknownInCatchVariables: false— the catch var defaults toany. Fix the tsconfig instead of working around it. - Catch blocks that immediately re-throw without inspecting the error (
catch (e) { throw new WrappedError('sync failed', { cause: e }) }) —causeisunknownonErrorOptions, no narrowing needed. - Top-level "log and rethrow" handlers where you genuinely just stringify and pass along —
String(e)is sufficient without further narrowing.
Why this matters: A handler that crashes loses the original error AND adds a misleading new one. Narrow once, then handle.
Reference: Clean Code, Chapter 7: Error Handling, TypeScript 4.4: `useUnknownInCatchVariables`
Never Swallow Errors Silently
catch (e) {} is the single highest-leverage bug factory in any codebase. Every silent catch is a future incident waiting to happen — by the time you notice the symptom, you've lost the cause. At minimum, log; ideally, report to telemetry and re-throw as a domain error. If you genuinely want to continue past a failure, say so explicitly with context.
Incorrect (failure is invisible until it shows up as a metrics anomaly):
async function placeOrder(cart: Cart) {
const order = await createOrder(cart);
try {
await sendAnalytics({ event: 'order_placed', orderId: order.id });
} catch {} // analytics shouldn't block checkout, so we swallow it
// Three months later: nobody notices that the analytics endpoint
// has been returning 500s for two weeks. Funnel data is corrupted.
return order;
}Correct (same user outcome; failure is observable):
async function placeOrder(cart: Cart) {
const order = await createOrder(cart);
try {
await sendAnalytics({ event: 'order_placed', orderId: order.id });
} catch (e) {
// Checkout still succeeds, but the failure is recorded and alertable.
reportError(e, { context: 'analytics', orderId: order.id });
}
return order;
}When NOT to apply this pattern:
- This rule is nearly absolute; the closest legitimate exception is a documented fire-and-forget where the API explicitly contracts no error reporting — even then, prefer logging at debug level.
- Test teardowns where errors are expected and the test asserts on them — but use
.rejects.toThrow(...)orexpect.assertions(...)rather than an empty catch. Promise.allSettledconsumers where the per-promise failure is intentionally collected as a result, not silenced — the rejection is captured in the settled result.
Why this matters: You can't fix what you can't see. A swallowed error is a future incident with the evidence already discarded.
Reference: Clean Code, Chapter 7: Error Handling — Don't Return Null / Don't Pass Null, Google SRE Book — Monitoring
Pick null OR undefined Per Domain — Not Both
TypeScript distinguishes null from undefined, but most codebases use them inconsistently and force callers to check for both. Pick one convention for your domain and stick to it — typically undefined for "absent / not requested" (matches optional fields, ?. chains, default args), reserving null for explicit DB-style "intentionally cleared." Document the choice in the team conventions.
Incorrect (mixed sentinels across the same module):
// Caller has to remember which function uses which sentinel.
function findUserById(id: string): User | null {
return db.users.findOne({ id }) ?? null;
}
function findEmailForUser(user: User): string | undefined {
return user.contacts.find((c) => c.type === 'email')?.value;
}
// Result: caller writes inconsistent checks.
const user = findUserById(id);
if (user === null) return; // null check
const email = findEmailForUser(user);
if (email === undefined) return; // undefined check
// Refactoring either function risks getting the sentinel wrong.Correct (one convention — `undefined` for absence — applied everywhere):
// Team convention: use `undefined` for "absent". `null` only when bridging
// to an external system that distinguishes (e.g., a JSON column).
function findUserById(id: string): User | undefined {
return db.users.findOne({ id }) ?? undefined;
}
function findEmailForUser(user: User): string | undefined {
return user.contacts.find((c) => c.type === 'email')?.value;
}
// Caller uses the same idiom everywhere — and `??` / `?.` work naturally.
const user = findUserById(id);
if (!user) return;
const email = findEmailForUser(user);
if (!email) return;When NOT to apply this pattern:
- External-API boundaries that distinguish the two — JSON treats
nullas explicit absence and a missing field asundefined; preserving the distinction at the boundary matters. - ORM / database layers where
nullis the SQLNULLsemantic and round-trips through the schema — converting at the boundary is fine, but don't pretend they're the same inside the DB layer. - Large legacy codebases — converge gradually as files are touched; a sweeping migration is rarely worth the diff.
Why this matters: Two sentinels for the same concept double the surface area for bugs. Pick one, codify it, and let ?? / ?. do the rest.
Reference: Clean Code, Chapter 7: Error Handling — Don't Return Null, Matt Pocock on null vs undefined
Choose Throw vs Result Deliberately
Throwing is for truly exceptional conditions — the network died, the DB connection dropped, an invariant was violated. Predictable failures — "user not found", "coupon expired", "balance too low" — are part of the domain's happy path and should be returned as a Result<T, E> discriminated union so callers must handle them. TypeScript doesn't track which exceptions can be thrown; it does track union variants.
Incorrect (predictable failure thrown; caller forgets to wrap):
// Caller has no type-level signal that this can fail; an everyday "user
// logged out" turns into an uncaught exception in production.
async function findUserById(id: string): Promise<User> {
const row = await db.users.where({ id }).first();
if (!row) throw new Error(`user ${id} not found`);
return row;
}
// Somewhere in a route handler:
const user = await findUserById(req.params.id); // crashes on logout flowCorrect (failure is a value; the type forces handling):
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
async function findUserById(
id: string,
): Promise<Result<User, 'not_found' | 'db_error'>> {
try {
const row = await db.users.where({ id }).first();
if (!row) return { ok: false, error: 'not_found' };
return { ok: true, value: row };
} catch {
return { ok: false, error: 'db_error' };
}
}
// Caller can't reach .value without handling the error variant:
const result = await findUserById(req.params.id);
if (!result.ok) return res.status(404).json({ error: result.error });
const user = result.value;When NOT to apply this pattern:
- When the framework's idiom IS throwing — React Suspense for data fetching throws the promise; that's the contract, and wrapping it in
Resultbreaks the integration. - When callers genuinely don't care about the failure mode (analytics, telemetry, background logging) — let it throw to the nearest boundary that does care.
- Public library APIs where verbose
Resulttypes harm ergonomics enough to hurt adoption — a single well-named exception class can be acceptable.
Why this matters: Types are the only documentation the compiler enforces. Encode predictable failures there.
Reference: Clean Code, Chapter 7: Error Handling, Matt Pocock on Result types
Use Suspense for Loading States, Not Boolean Flags
The const [isLoading, setIsLoading] = useState(true) pattern forces every data-displaying component to repeat the same if (loading) return <Spinner />; if (error) return <Err />; prelude. Suspense inverts this: declare a loading boundary once at the page or section level, and child components consume data as if it's always present. Combined with React 19's use() or Suspense-aware query hooks, this collapses three branches into one.
Incorrect (every component carries its own loading/error scaffolding):
function OrderDetails({ orderId }: { orderId: string }) {
const { data: order, isLoading, error } = useQuery({
queryKey: ['order', orderId],
queryFn: () => fetchOrder(orderId),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!order) return null;
return (
<div>
<h1>Order #{order.number}</h1>
<CustomerCard customerId={order.customerId} />
{/* CustomerCard repeats the same isLoading/error pattern internally. */}
</div>
);
}Correct (boundaries at the section level; child components are linear):
function OrderDetailsPage({ orderId }: { orderId: string }) {
return (
<ErrorBoundary FallbackComponent={OrderErrorFallback}>
<Suspense fallback={<Spinner />}>
<OrderDetails orderId={orderId} />
</Suspense>
</ErrorBoundary>
);
}
function OrderDetails({ orderId }: { orderId: string }) {
// useSuspenseQuery (or React 19 `use(promise)`) guarantees `order` is defined.
const { data: order } = useSuspenseQuery({
queryKey: ['order', orderId],
queryFn: () => fetchOrder(orderId),
});
// Linear render — no loading/error branches in the body.
return (
<div>
<h1>Order #{order.number}</h1>
<CustomerCard customerId={order.customerId} />
</div>
);
}When NOT to apply this pattern:
- Legacy components using non-Suspense data fetching — don't rewrite working code just to switch idioms; migrate as you touch them.
- Per-row or per-cell loading UIs (e.g., a table where each row independently fetches and shows a spinner) — local
isLoadingper row is clearer than nested Suspense boundaries. - Non-data async state like form submission (
isSubmitting) or button-level "saving..." indicators — local state is the right tool.
Why this matters: Suspense moves loading from a per-component concern to a per-region concern, which is where designers think about it anyway.
Reference: react.dev — Suspense, TkDodo: React 19 and Suspense — a Drama in 3 Acts
Group Imports by Source
The import block at the top of a file is a dependency map: external packages, internal aliased modules, sibling files. Grouping them — external first, then aliased internal, then relative — and separating groups with blank lines makes that map scannable. Mixing them turns it into noise.
Incorrect (imports in random arrival order):
import { useCart } from './hooks/useCart';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import type { Order } from '@/types/order';
import { formatPrice } from './utils';
import { trpc } from '@/lib/trpc';
// Reader can't tell at a glance which deps are external vs internal vs local.Correct (three groups, blank line between, external → aliased → relative):
import { useState } from 'react';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { trpc } from '@/lib/trpc';
import type { Order } from '@/types/order';
import { useCart } from './hooks/useCart';
import { formatPrice } from './utils';
// Reader sees: "two external deps, three project-internal, two local".
// Adding a new dep is obvious — it goes in its group.When NOT to apply this pattern:
- Single-import files — one line, nothing to group.
- Build tools or formatters that automatically reorder imports with a different (but consistent) policy — follow the tool rather than fight it.
- Generated files (codegen output, migration files) where the generator owns the import block.
Why this matters: The import block is read every time a file is opened. Spending a few blank lines on structure pays back on every read.
Reference: Clean Code, Chapter 5: Formatting, `eslint-plugin-import` order rule
Order Files Top-Down Like a Newspaper
A newspaper article puts the headline first, then the lede, then supporting details. A code file should do the same: the exported, high-level entity at the top; the helpers that support it below. A reader opening the file should see what it's FOR before they see how it works.
Incorrect (helpers first; reader scrolls to find the headline):
// File: Checkout.tsx — but you can't tell that by reading top-down.
import { z } from 'zod';
import type { Cart, Coupon } from '@/types';
function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
function validateCoupon(code: string, cart: Cart): Coupon | null {
// ... 30 lines ...
}
function calculateShipping(cart: Cart): number {
// ... 20 lines ...
}
// Reader scrolls 80 lines down before discovering what this file exports.
export default function Checkout({ cart }: { cart: Cart }) {
// ... uses the helpers above ...
}Correct (export at the top; helpers below):
// File: Checkout.tsx — purpose visible immediately.
import { z } from 'zod';
import type { Cart, Coupon } from '@/types';
export default function Checkout({ cart }: { cart: Cart }) {
// Reader sees what this file is FOR first; helpers are an implementation
// detail one scroll away.
const shipping = calculateShipping(cart);
// ... uses the helpers below ...
}
function calculateShipping(cart: Cart): number {
// ... 20 lines ...
}
function validateCoupon(code: string, cart: Cart): Coupon | null {
// ... 30 lines ...
}
function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}When NOT to apply this pattern:
- Tooling that expects a specific position for exports (some bundlers and test runners care about the position of
export defaultor named exports — follow the tool). - Established team convention of "imports → constants → types → helpers → exports" — consistency across the codebase trumps any single rule.
- Types and interfaces consumed by the export — often clearest defined right above the export so the reader sees the contract before the implementation.
Why this matters: A file's first 20 lines are its abstract. Spend them on the entity the file exists for, not on plumbing.
Reference: Clean Code, Chapter 5: Formatting — The Newspaper Metaphor
Team Conventions Over Personal Preference
A codebase where every file follows the same imperfect convention is more readable than one where each author optimized locally with their own perfect convention. New code should look like the surrounding code, even when you disagree, because consistency reduces the cost of context-switching for every reader. Encode the convention in ESLint so it doesn't depend on memory.
Incorrect (each author chose their own export style):
// File: features/orders/OrderList.tsx
function OrderList({ orders }: { orders: Order[] }) {
return <ul>{/* ... */}</ul>;
}
export default OrderList;
// File: features/orders/OrderRow.tsx — different convention, same project
export const OrderRow = ({ order }: { order: Order }) => {
return <li>{order.id}</li>;
};
// File: features/orders/OrderFilters.tsx — a third style
export function OrderFilters(props: { onChange: (f: Filter) => void }) {
return <form>{/* ... */}</form>;
}Every reader has to re-orient on every file: default vs named, arrow vs function, destructured props vs props.x.
Correct (one convention, applied everywhere, enforced by lint):
// .eslintrc.cjs: 'import/no-default-export', custom rule for named function components.
// File: features/orders/OrderList.tsx
export function OrderList({ orders }: { orders: Order[] }) {
return <ul>{/* ... */}</ul>;
}
// File: features/orders/OrderRow.tsx
export function OrderRow({ order }: { order: Order }) {
return <li>{order.id}</li>;
}
// File: features/orders/OrderFilters.tsx
export function OrderFilters({ onChange }: { onChange: (f: Filter) => void }) {
return <form>{/* ... */}</form>;
}Same shape on every file; readers learn the pattern once.
When NOT to apply this pattern:
- When the team convention is actively harmful (e.g., banning
async/awaitin favor of.thenchains, or requiring class components in 2026) — make the case to change it, but until it changes, follow it. - Greenfield code with no established convention yet — propose one explicitly, write it down, and then start applying it.
- Large legacy migrations: don't reformat 10,000 untouched lines just to align with the new style; let conversion happen as files are touched for real reasons.
Why this matters: Consistency is a force multiplier on readability. The "best" rule applied inconsistently is worse than a "good enough" rule applied everywhere.
Reference: Clean Code, Chapter 5: Formatting — Team Rules, ESLint shared configs
Keep Related Code Close, Unrelated Code Far
Vertical distance between two related lines forces the reader's eye to track context across the file. Variables should be declared near where they are used; helpers should sit near their primary caller; unrelated blocks should be separated by blank lines. Prettier can't enforce this — it's a judgment about how a human reads top to bottom.
Incorrect (related lines stretched apart by 40 lines):
function generateInvoice(order: Order) {
let subtotal = 0;
const lineItems: LineItem[] = [];
const discounts: Discount[] = [];
const taxes: Tax[] = [];
// ... 30 lines processing line items, populating lineItems ...
for (const item of order.items) {
lineItems.push({ /* ... */ });
}
// ... 10 more lines unrelated to subtotal ...
applyShipping(order);
validateAddress(order.address);
// Reader has to jump back to line 2 to remember `subtotal` exists.
for (const item of lineItems) {
subtotal += item.price * item.quantity;
}
return { subtotal, lineItems, discounts, taxes };
}Correct (declare `subtotal` immediately before the loop that uses it):
function generateInvoice(order: Order) {
const lineItems: LineItem[] = [];
const discounts: Discount[] = [];
const taxes: Tax[] = [];
for (const item of order.items) {
lineItems.push({ /* ... */ });
}
applyShipping(order);
validateAddress(order.address);
// Declaration sits right next to its only use — no eye-jumping.
let subtotal = 0;
for (const item of lineItems) {
subtotal += item.price * item.quantity;
}
return { subtotal, lineItems, discounts, taxes };
}When NOT to apply this pattern:
- React function components: hooks must be at the top in stable order (Rules of Hooks), even if a
useStateis only read at the bottom of the JSX. The framework constraint wins. - Team style guides that mandate "all variables declared at function top" — consistency across the codebase outweighs local optimization.
- Constants and configuration intentionally hoisted to the top of a module for visibility, even if only one function uses them.
Why this matters: Reading is the bottleneck of software maintenance. Vertical locality is one of the cheapest tools to reduce working memory load.
Reference: Clean Code, Chapter 5: Formatting — Vertical Distance
One Level of Abstraction Per Function
A function that calls repository.save(invoice) (high-level orchestration) and also computes invoice.items.reduce((s, i) => s + i.qty * i.price * (1 - i.discount), 0) (low-level arithmetic) forces the reader to constantly shift gears. Keep one level per function: either orchestrate the what, or compute the how, never both in the same body.
Incorrect (high-level call mixed with low-level conditional and arithmetic):
// Reader's eye jumps: "save invoice" → low-level boolean trio → "send email".
async function finalizeInvoice(invoice: Invoice): Promise<void> {
if (
invoice.items.length > 0 &&
invoice.total > 0 &&
!invoice.archived &&
invoice.dueDate.getTime() > Date.now()
) {
await invoiceRepository.save(invoice);
await mailer.send(invoice.customerEmail, `Invoice ${invoice.id} ready`);
}
}Correct (low-level predicate extracted; outer function reads as policy at one level):
// Outer function now reads as a single sentence at the policy level.
async function finalizeInvoice(invoice: Invoice): Promise<void> {
if (!isInvoiceShippable(invoice)) return;
await invoiceRepository.save(invoice);
await mailer.send(invoice.customerEmail, `Invoice ${invoice.id} ready`);
}
function isInvoiceShippable(invoice: Invoice): boolean {
return (
invoice.items.length > 0 &&
invoice.total > 0 &&
!invoice.archived &&
invoice.dueDate.getTime() > Date.now()
);
}When NOT to apply this pattern:
- Leaf functions where there is no higher level —
isInvoiceShippableitself contains only low-level checks because that is its job. Don't recursively extract the&&chain. - React event handlers that have a single trivial line of detail before the callback (
onClick={() => setOpen(false)}) — extractinghandleClosefor onesetStatecall is often noisier than the inline version. - Performance-critical hot paths where extracting a predicate adds a function call per iteration of a tight loop; sometimes the mixed-level body is the right trade.
Why this matters: Single-level functions read top-down like prose; mixed-level functions force the reader to re-tune their abstraction every line.
Type Safety Loses to Ergonomics at Stable Boundaries
Maximally precise types catch maximally many bugs — in theory. In practice, callers must understand the types to use the API. Beyond a complexity threshold, precision becomes friction: devs write as any, copy-paste from working call sites without understanding why they work, and you've lost both safety AND ergonomics. At stable internal boundaries, boring beats clever.
Incorrect (dogmatic precision — signature as puzzle):
// Technically precise. Catches a few extra bugs the boring version wouldn't.
// In exchange: every caller stares at the signature for five minutes, and
// the team starts writing `as any` to bypass it.
function createUser<
T extends Record<string, unknown>,
K extends keyof T & string,
>(
data: T,
...requiredKeys: [K, ...K[]]
): Pick<T, K> & { id: UserId; createdAt: Date } {
// ...
}
// Caller spends real time figuring out what to pass.
const user = createUser({ email: 'a@b.c', name: 'A' } as const, 'email', 'name');Correct (balanced — precise where it pays, boring where it doesn't):
// Boring. Obvious. Used correctly without thought.
// The precision the old signature added was buying nothing real:
// callers already pass CreateUserInput-shaped objects.
type CreateUserInput = {
email: string;
name: string;
};
function createUser(input: CreateUserInput): User {
// ...
}
const user = createUser({ email: 'alice@example.com', name: 'Alice' });When NOT to apply this pattern:
- Library APIs where the types ARE the contract — e.g., a fetcher that infers response shape from a route declaration; the gymnastics are doing real work for many unknown callers.
- Security-critical paths where loose types could allow exploitation (auth tokens, permission checks, sanitization) — pay the precision cost.
- Codebases with experienced TS teams where precision is the local norm — consistency with the codebase outweighs absolute ergonomics.
Why this matters: Type precision and ergonomics are both forms of intent communication — to the compiler and to the human. Optimizing one to the destruction of the other defeats both.
Reference: Clean Code, Chapter 4: Comments (intent over precision), Matt Pocock — Total TypeScript
Related skills
FAQ
What does clean-code-ts-react do?
clean-code-ts-react is a Claude Code skill for frontend development.
When should I use clean-code-ts-react?
When you need to helps with frontend development tasks during AI-assisted development., or when clean-code-ts-react is a claude code skill for frontend development.
What are the main capabilities?
clean-code-ts-react; Frontend Development; AI-coding skill.