Clean Code in React and TypeScript

Write React and TypeScript code that is easy to read, easy to change, and easy to trust — through disciplined naming, small functions, single responsibility, minimal duplication, honest error handling, and types that document intent.

Clean code is not a style preference — it is a cost-reduction strategy. Every unclear name, ten-responsibility component, and swallowed error is a small tax that someone (often you, six months later) pays with interest. This lesson works through ten concrete practices — naming, small functions, single responsibility, duplication, error handling, comments, types as documentation, refactoring, code review, and abstraction — each with a before/after example grounded in React and TypeScript.

Naming: the cheapest documentation you will ever write

A name is read far more often than it is written. data, handleClick, temp, and flag all compile fine and all cost the next reader time decoding what they actually mean. A good name answers "what is this" and "why does it exist" without needing a comment.

In React specifically, name booleans as predicates (isLoading, hasError, canSubmit), name handlers by the action they perform (handleSubmitOrder, not handleClick), and name hooks by what they return, not how they work (useCurrentUser, not useFetchEffect).

• Prefer isVisible over visible for booleans — reads naturally in conditionals. • Prefer orderTotal over total when the type alone does not disambiguate. • Avoid abbreviations that save four keystrokes and cost four seconds of reader time (usr, cfg, idx are fine only when conventional in context, e.g. loop i). • A name that needs a comment to explain it should usually just become a better name instead.

Small functions and single responsibility

A function or component should do one thing at one level of abstraction. If a component both fetches data, transforms it, validates a form, and renders a chart, it has at least four responsibilities and four reasons to change. Each reason to change is a place a future edit can introduce a bug in an unrelated area.

The practical test: can you describe what the function does in one sentence without using "and"? If not, it is a candidate for splitting. Small functions are also easier to name well — a function that does one thing has one clear purpose to name after.

• Extract data transformation out of components into plain functions or hooks. • Extract validation logic into its own testable function, separate from the submit handler. • A component over ~150 lines, or a function with more than 2-3 levels of nested conditionals, is worth a second look.

Duplication, error handling, and comments

Duplicated logic is a maintenance liability: fix a bug in one copy, forget the other, and you have introduced an inconsistency instead of a fix. The rule of three is a reasonable heuristic — the first occurrence is fine, the second is a coincidence worth watching, the third is a signal to extract a shared function or hook.

Error handling in React/TypeScript means never silently swallowing a rejected promise, always distinguishing expected failures (validation errors, 404s) from unexpected ones (network failure, bugs), and surfacing errors to the UI rather than only to the console.

Comments should explain *why*, never *what* — the code already says what it does. A comment that restates the next line in English is noise; a comment that explains a non-obvious business rule or a workaround for a browser bug is valuable.

• Rule of three: extract shared logic once the same pattern appears a third time. • Never catch an error and do nothing (an empty catch block) — log it, rethrow it, or show it. • Comment the "why" (business rule, workaround, tradeoff) — never the "what" the code already shows.

Types as documentation, refactoring, review, and abstraction

TypeScript types are executable documentation: unlike a comment, they cannot silently drift out of sync with the code, because the compiler enforces them. A well-designed type (a discriminated union for state, a branded type for an ID, a precise prop interface) tells the reader what is and is not possible far more reliably than a paragraph of prose.

Refactoring is changing code structure without changing behavior — and it only stays safe when you have tests or types to catch mistakes, and you take small, verifiable steps rather than one giant rewrite. Code review exists to catch what the author cannot see in their own work: unclear names, missed edge cases, and unintended duplication with code elsewhere in the codebase.

Abstraction hides complexity behind a simple interface — but a bad abstraction (one that leaks its internals, or is built for a single caller "just in case") is worse than no abstraction. Prefer a slightly duplicated, obvious piece of code over a clever, over-general abstraction used in only one place.

• A discriminated union ({ status: "idle" } | { status: "loading" } | { status: "error"; message: string } | { status: "success"; data: T }) makes invalid states unrepresentable. • Refactor in small, behavior-preserving steps, verified by tests or the type checker at each step. • In review, ask "could I understand this file cold, with no context?" — if not, say so, kindly and specifically. • Do not build an abstraction for a single use case — wait for the third real use before generalizing (same rule-of-three logic as duplication).

A discriminated-union type as documentation

TypeScript narrows state inside each case, so state.message and state.data are only accessible where they are actually guaranteed to exist. This is a type doing the job a paragraph of comments used to do, and it cannot go stale the way a comment can.

// Instead of loosely-related booleans that can contradict each other:
interface BadState {
  isLoading: boolean;
  isError: boolean;
  data: User | null;
  error: string | null;
} // isLoading=true AND isError=true AND data set is representable but nonsensical

// A discriminated union makes invalid combinations impossible to construct:
type UserState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: User };

function renderUser(state: UserState) {
  switch (state.status) {
    case 'idle': return null;
    case 'loading': return <Spinner />;
    case 'error': return <ErrorBanner message={state.message} />; // message only exists here
    case 'success': return <UserCard user={state.data} />;         // data only exists here
  }
}

Code Examples

This component fetches, handles loading/error state, computes aggregates, and renders — four responsibilities in ~20 lines, with any types hiding what shape the data actually has. A bug in the average calculation requires reading the whole component to find it.

Common mistake: Mixing data fetching, error handling, business math, and rendering in a single component with no type safety.

Each piece now has one job: useStats owns fetching and error handling, calculateStatsSummary is a pure, unit-testable function with no React dependency, and Dashboard only composes and renders. The discriminated union makes the error and success states mutually exclusive and type-safe, eliminating the any types entirely.

function calculateStatsSummary(items: StatItem[]) { ... } // pure, testable, no React

This message-formatting logic was copy-pasted with small variations into three forms. Once a fourth form needed a slightly different wording, two of the three copies had already drifted. Extracting it once (after the third occurrence, per the rule of three) means one place to fix wording, pluralization, or i18n later.

function Dashboard() {
  const [data, setData] = useState<any>(null);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/stats').then(r => r.json()).then(setData).catch(e => setErr(e.message));
  }, []);

  if (err) return <div>error</div>;
  if (!data) return <div>...</div>;

  const total = data.items.reduce((a: number, b: any) => a + b.value, 0);
  const avg = total / data.items.length;

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Total: {total}</p>
      <p>Avg: {avg}</p>
      {data.items.map((i: any) => <div key={i.id}>{i.name}: {i.value}</div>)}
    </div>
  );
}
interface StatItem { id: string; name: string; value: number; }
interface StatsResponse { items: StatItem[]; }

type StatsState =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: StatsResponse };

// Single responsibility: fetch and expose state, nothing else.
function useStats(): StatsState {
  const [state, setState] = useState<StatsState>({ status: 'loading' });

  useEffect(() => {
    let cancelled = false;
    fetch('/api/stats')
      .then((r) => {
        if (!r.ok) throw new Error(`Failed to load stats: ${r.status}`);
        return r.json() as Promise<StatsResponse>;
      })
      .then((data) => { if (!cancelled) setState({ status: 'success', data }); })
      .catch((e: Error) => { if (!cancelled) setState({ status: 'error', message: e.message }); });
    return () => { cancelled = true; };
  }, []);

  return state;
}

// Single responsibility: pure math, easily unit-testable.
function calculateStatsSummary(items: StatItem[]) {
  const total = items.reduce((sum, item) => sum + item.value, 0);
  const average = items.length > 0 ? total / items.length : 0;
  return { total, average };
}

// Single responsibility: render 
…
// Appeared in OrderForm, ProfileForm, and SignupForm (third occurrence -> extract)
function formatValidationError(field: string, rule: 'required' | 'email' | 'minLength', min?: number): string {
  switch (rule) {
    case 'required': return `${field} is required.`;
    case 'email': return `${field} must be a valid email address.`;
    case 'minLength': return `${field} must be at least ${min} characters.`;
  }
}

Important Rules

A name that needs a comment to explain it should become a better name instead.

A function or component should be describable in one sentence without the word "and".

Extract shared logic on the third occurrence (rule of three) — not the first, not never.

Never leave a catch block empty; log, rethrow, or surface every error.

Comment the why (business rule, tradeoff, workaround), never the what the code already shows.

Prefer a discriminated union over a set of independent booleans for anything with mutually exclusive states.

Refactor in small, verifiable steps — never a single unreviewable rewrite.

Do not generalize an abstraction for a single caller; wait for a real second and third use case.

Where this pays off

Codebases with disciplined naming, small single-responsibility functions, and typed state consistently show faster onboarding for new engineers and fewer regressions during refactors, because reviewers and new hires can trust what a name or type says without reading every call site. The upfront cost — an extra five minutes naming something well or splitting a component — is repaid every time someone else (or future you) reads that code.

const [x, setX] = useState(false); // used for both "loading" and "disabled" at different times const [status, setStatus] = useState<"idle" | "loading" | "error" | "success">("idle");

Reusing one ambiguous boolean for multiple meanings is a naming and modeling failure that a discriminated union or clearly-named state resolves.

try { await saveOrder(order); } catch (e) {} // swallow silently try { await saveOrder(order); } catch (e) { setError(toMessage(e)); logError(e); }

An empty catch block hides failures from both the user and your error monitoring — the order silently fails to save with no signal anywhere.

What is the "rule of three" as applied to duplication? A. Never write a function longer than three lines B. Extract shared logic once the same pattern has appeared a third time, not on the first occurrence C. Always use exactly three levels of component nesting D. Write three unit tests for every function

B. Extract shared logic once the same pattern has appeared a third time, not on the first occurrence — The rule of three is a heuristic against both premature abstraction (extracting after one occurrence) and harmful duplication (never extracting): wait for a third real occurrence before generalizing.

Why is an empty catch block considered a clean-code violation? A. It causes a TypeScript compile error B. It silently hides failures from both the user and error monitoring, with no signal anywhere that something went wrong C. It is slower at runtime than an if statement D. It prevents the component from re-rendering

B. It silently hides failures from both the user and error monitoring, with no signal anywhere that something went wrong — Swallowing an error with no logging, rethrow, or UI feedback means the failure disappears entirely — nobody, including future debugging efforts, ever learns it happened.

Why do discriminated unions serve as better "documentation" than a comment for representing state? A. They run faster than plain objects B. The compiler enforces them, so they cannot silently drift out of sync with the code the way a comment can C. They automatically generate UI components D. They remove the need for any prop types

B. The compiler enforces them, so they cannot silently drift out of sync with the code the way a comment can — A comment can become inaccurate the moment code changes and nothing forces it to be updated. A discriminated union is checked by the compiler on every build, so it stays accurate by construction.

Take a component you have written that does more than one job (fetch + transform + render, or validate + submit + render) and split it into a hook for data/state, a pure function for any calculation, and a component for rendering only — following the Dashboard example above. Hint: Start by writing one sentence describing what the component currently does. Every "and" in that sentence is a seam to split along.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.