React Style Guide

A guide to writing React code that is easy to understand, maintain, and scale.

1. State Scope

Hold each piece of React state in the lowest common ancestor that consumes it; lift only when multiple siblings need the same data.

Minimises re‑render surface and keeps ownership obvious.

Why this rule exists (and how it’s often broken) When you’re new to React it’s tempting to stash all your state in a parent component “just to be safe.” Unfortunately, every time that state changes, everything below it re‑renders—even parts that don’t care. This slows things down, hides the real owner of the data, and makes future refactors painful. By keeping state close to where it’s used, each update only touches the components that actually need to react, which means faster screens and cleaner code.

Correct Example

import { useEffect, useState } from "react";

type Todo = { id: number; text: string };

export default function TodoList() {
  // Server‑fetched data belongs here: multiple items depend on it.
  const [todos, setTodos] = useState<Todo[]>([]);

  useEffect(() => {
    fetch("/api/todos")
      .then(res => res.json())
      .then(setTodos);
  }, []);

  return (
    <ul className="space-y-2">
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

function TodoItem({ todo }: { todo: Todo }) {
  // Checkbox state is local—only this row cares.
  const [checked, setChecked] = useState(false);

  return (
    <li className="flex items-center gap-2">
      <input
        type="checkbox"
        checked={checked}
        onChange={() => setChecked(!checked)}
      />
      <span className={checked ? "line-through" : ""}>{todo.text}</span>
    </li>
  );
}

Incorrect Example

// Hoisting every item’s checkbox state to the top component.
function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [checkedMap, setCheckedMap] = useState<Record<number, boolean>>({});

  useEffect(() => {
    fetch("/api/todos")
      .then(res => res.json())
      .then(setTodos);
  }, []);

  const toggle = (id: number) =>
    setCheckedMap(prev => ({ ...prev, [id]: !prev[id] }));

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={!!checkedMap[todo.id]}
            onChange={() => toggle(todo.id)}
          />
          {todo.text}
        </li>
      ))}
    </ul>
  );
}
// A single checkbox flips? The whole list (and any unrelated siblings)
// still re-renders. Performance tanks and intent is murky.

Key Takeaways

  • Own it where you use it: Let the component that reads and writes the state also own it.
  • Lift sparingly: Only move state up when two or more sibling branches genuinely need the same data or control.
  • Prop‑drilling is fine (within reason): Passing a few props downward beats a global re‑render upward.
  • Performance follows architecture: Local state limits render work and keeps React’s diffing efficient.

2. State Derivation

Do not store values that can be derived from props or other state; compute them on demand or memoise.

Prevents source‑of‑truth drift and reduces update cascades.

New React developers sometimes “cache” derived data in state because it feels faster or more convenient. The trap? You now have two sources of truth. Forget to keep them in sync and your UI lies to you—think stale counts, wrong filters, or layout glitches. React is already great at recomputing pure values during render; let it handle the math instead of managing more state than you need.

Correct Example

import { useMemo, useState } from "react";

type Product = { id: number; name: string; price: number };

export default function Cart({ products }: { products: Product[] }) {
  const [discount, setDiscount] = useState(0); // genuine state

  // Total is derived → compute, not store.
  const total = useMemo(
    () => products.reduce((sum, p) => sum + p.price, 0) * (1 - discount),
    [products, discount]
  );

  return (
    <section className="space-y-2">
      <h2 className="text-xl font-semibold">Cart</h2>
      {products.map((p) => (
        <p key={p.id}>
          {p.name} — ${p.price}
        </p>
      ))}
      <label className="block">
        Discount %
        <input
          type="number"
          value={discount * 100}
          onChange={(e) => setDiscount(+e.target.value / 100)}
          className="ml-2 w-20 border"
        />
      </label>
      <p className="font-bold">Total: ${total.toFixed(2)}</p>
    </section>
  );
}

Incorrect Example

// Trying to “cache” the total in state—easy to desync.
function CartBroken({ products }: { products: Product[] }) {
  const [discount, setDiscount] = useState(0);
  const [total, setTotal] = useState(0); // ❌ redundant state

  // Update total when products change (but what if we forget one?)
  useEffect(() => {
    const t = products.reduce((sum, p) => sum + p.price, 0) * (1 - discount);
    setTotal(t);
  }, [products]); // ❌ forgot to include discount!

  return (
    <>
      {/* …product list… */}
      <p>Total: ${total.toFixed(2)}</p> {/* stale after discount changes */}
    </>
  );
}

Outcome: Applying a discount doesn’t refresh total because the effect’s dependency list is incomplete—classic “derived state drift.”

Key Takeaways

  • Single source of truth: If a value can be calculated from existing props/state, keep it out of state.
  • Compute or memoize: Use plain expressions in render, or useMemo when the calculation is heavy.
  • Less state, fewer bugs: Every extra useState is a synchronization liability—eliminate the unnecessary ones.
  • Dependency safety: Derived data in useMemo/useEffect must list every input; forgetting one re‑creates drift.

3. Prop Immutability

Treat props as immutable inputs—never mutate them or depend on external mutation.

Preserves one‑way data flow and enables predictable rendering.

React is built on a one‑way data flow: parents pass values to children, children never reach back up to change those values in place. When a component mutates a prop—or relies on a parent mutating an object it passed down—React’s change detection can’t see what happened. The UI drifts out of sync, memoization breaks, and seemingly random bugs appear. Newcomers often mutate props “just to toggle a flag” or to push into an array, not realizing they’re sidestepping React’s render cycle.

Correct Example

type Todo = { id: number; text: string; done: boolean };

function TodoItem({
  todo,
  onToggle,
}: {
  todo: Todo;
  onToggle: (id: number) => void;
}) {
  return (
    <label className="flex items-center gap-2">
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
      />
      <span className={todo.done ? "line-through" : ""}>{todo.text}</span>
    </label>
  );
}

export default function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [todos, setTodos] = useState(initialTodos);

  const toggle = (id: number) =>
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );

  return (
    <ul className="space-y-2">
      {todos.map((t) => (
        <li key={t.id}>
          <TodoItem todo={t} onToggle={toggle} />
        </li>
      ))}
    </ul>
  );
}

What’s happening? TodoItem never mutates its todo prop. Instead, it notifies the parent (onToggle), which replaces the relevant object with a new one—so React sees a new reference and re‑renders the right bits.

Incorrect Example

function TodoItemMutable({
  todo,
}: {
  todo: { id: number; text: string; done: boolean };
}) {
  // ❌ Mutating the prop directly
  const flip = () => {
    todo.done = !todo.done; // mutation in place
  };

  return (
    <label>
      <input type="checkbox" checked={todo.done} onChange={flip} />
      {todo.text}
    </label>
  );
}

export default function TodoListBroken({
  initialTodos,
}: {
  initialTodos: Todo[];
}) {
  // Parent never sees a *new* object—React thinks nothing changed.
  const [ignored, forceRerender] = useReducer((x) => x + 1, 0);

  return (
    <>
      {initialTodos.map((t) => (
        <TodoItemMutable key={t.id} todo={t} />
      ))}
      <button onClick={forceRerender}>Force refresh</button>
    </>
  );
}

Outcome: The checkbox toggles internally, but the parent state never updates. Other components relying on initialTodos stay stale unless you force a rerender—exactly the kind of hidden bug immutability avoids.

Key Takeaways

  • Props are read‑only: Child components can react to them but must never mutate them.
  • Pass callbacks, not mutations: To change data, bubble an intent upward via a function prop; let the owner replace objects immutably.
  • Immutable updates = predictable UI: New references tell React “something changed,” enabling reliable diffing and memoization.
  • Red flags: Direct assignment to a prop object, pushing into props.items, or calling methods that mutate incoming data—all signal design smells.

4. Component Single Responsibility

Keep each component focused on one concern (UI, state management, or orchestration—never all three).

Improves reuse, testability, and cognitive load.

A “kitchen‑sink” component that fetches data, owns state, and renders UI feels convenient—until you need to reuse the UI elsewhere, write unit tests, or tweak the data flow. Bloated components turn every small change into a refactor risk and hide the mental model of the app. By giving each component a single job, you keep files short, tests targeted, and future changes isolated.

Correct Example

// UserListContainer.tsx
import { useEffect, useState } from "react";
import UserList from "./UserList";

type User = { id: number; name: string; avatar: string };

export default function UserListContainer() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Loading…</p>;
  return <UserList users={users} />;
}
// UserList.tsx  — purely presentational
type User = { id: number; name: string; avatar: string };

export default function UserList({ users }: { users: User[] }) {
  return (
    <ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
      {users.map((u) => (
        <li
          key={u.id}
          className="flex items-center gap-3 rounded-xl p-4 shadow"
        >
          <img src={u.avatar} alt={u.name} className="h-10 w-10 rounded-full" />
          <span className="font-medium">{u.name}</span>
        </li>
      ))}
    </ul>
  );
}

What’s happening?

  • UserListContainer handles data retrieval and loading state.
  • UserList focuses solely on rendering the list. Either piece can now be reused, tested, or swapped independently.

Incorrect Example

function UserListMonolith() {
  // 👉 Data fetching
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [search, setSearch] = useState(""); // 👉 UI state
  const [selected, setSelected] = useState<User | null>(null);

  useEffect(() => {
    fetch("/api/users")
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Loading…</p>;

  // 👉 Rendering + orchestration all mashed together
  return (
    <>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        className="mb-4 border p-2"
        placeholder="Search users"
      />
      <ul>
        {users
          .filter((u) => u.name.toLowerCase().includes(search.toLowerCase()))
          .map((u) => (
            <li key={u.id} onClick={() => setSelected(u)}>
              {u.name}
            </li>
          ))}
      </ul>
      {selected && (
        <div className="fixed inset-0 grid place-content-center bg-black/40">
          <div className="rounded bg-white p-6">
            <h2 className="text-lg font-semibold">{selected.name}</h2>
            {/* more UI */}
            <button onClick={() => setSelected(null)}>Close</button>
          </div>
        </div>
      )}
    </>
  );
}
// One file handles fetching, filtering, modal logic, and UI.
// Reusing the list or the modal elsewhere now means copy‑pasting.

Key Takeaways

  • One job per component: Separate data fetching (containers), stateful logic (hooks), and presentation (UI components).
  • Small components test easier: A render‑only component needs no network mocks; a data hook needs no DOM.
  • Better reuse: Container components can swap in different UIs (mobile vs. desktop) without touching fetch logic.
  • Clear mental model: When each file is focused, new teammates can trace flow quickly instead of parsing a 300‑line blob.

5. Component Functional

Use function components and Hooks exclusively; avoid class components in new code.

Aligns with the current React runtime and future features (concurrency, RSC).

React’s modern features—Concurrent Rendering, Suspense, React Server Components—are designed around function components + Hooks. Class components still work, but they lock you out of new APIs and add boilerplate (binding, lifecycles, this issues). Teams often keep reaching for classes out of habit or to copy old tutorials, only to discover that mixing paradigms complicates state management and hurts upgrade paths.

Correct Example

// Counter.tsx — idiomatic Hook‑based component
import { useState, useEffect } from "react";

export default function Counter({ step = 1 }: { step?: number }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `Count: ${count}`; // side‑effect in a Hook
  }, [count]);

  return (
    <button
      onClick={() => setCount((c) => c + step)}
      className="rounded-xl bg-blue-600 px-4 py-2 font-medium text-white"
    >
      Clicked {count} times
    </button>
  );
}

What’s happening?

  • State (useState) and side‑effects (useEffect) live in a plain function—no this, no bindings.
  • Works seamlessly with React’s future‑proof rendering modes.

Incorrect Example

// CounterClass.tsx — legacy approach
import { Component } from "react";

interface Props {
  step?: number;
}
interface State {
  count: number;
}

export default class CounterClass extends Component<Props, State> {
  state: State = { count: 0 };

  componentDidMount() {
    document.title = `Count: ${this.state.count}`;
  }
  componentDidUpdate() {
    document.title = `Count: ${this.state.count}`;
  }

  handleClick = () => {
    this.setState((prev) => ({ count: prev.count + (this.props.step ?? 1) }));
  };

  render() {
    const { count } = this.state;
    return (
      <button
        onClick={this.handleClick}
        className="rounded-xl bg-blue-600 px-4 py-2 font-medium text-white"
      >
        Clicked {count} times
      </button>
    );
  }
}
// Extra boilerplate, harder to compose with modern features, and incompatible
// with React Server Components or certain concurrent patterns.

Key Takeaways

  • Future‑proof by default: Function components are required for Suspense for Data Fetching, RSC, and new Hook‑based APIs.
  • Less boilerplate: No this, lifecycle method maze, or manual bindings—just plain JavaScript functions.
  • Easier composition: Custom Hooks let you share logic without HOCs or render props.
  • Gradual adoption: If you still have class components, wrap new work in functions and migrate the old code incrementally.

6. Pure Render

Write render logic as a pure function of props and state; never trigger side‑effects during render.

Ensures compatibility with strict / concurrent rendering modes.

React calls your component’s render function a lot—sometimes twice in development (Strict Mode) and, with Concurrent Rendering, potentially even more. If that render kicks off a network request, mutates global data, or directly manipulates the DOM, you’ll get duplicate calls, race conditions, or even infinite loops. Beginners often sprinkle “just one fetch” or localStorage.setItem right inside render, unaware that React expects render to be a pure calculation: same inputs → same output → zero side‑effects.

Correct Example

import { useEffect, useState } from "react";

export default function RandomJoke() {
  const [joke, setJoke] = useState<string | null>(null);

  // Side‑effect (network fetch) lives in useEffect, not render.
  useEffect(() => {
    fetch("https://api.chucknorris.io/jokes/random")
      .then((res) => res.json())
      .then((data) => setJoke(data.value));
  }, []); // run once after initial mount

  // Render stays pure—just reflects current state.
  return (
    <section className="rounded-xl border p-4">
      {joke ?? "Loading a hilarious joke…"}
    </section>
  );
}

What’s happening? Render merely transforms joke state into UI. All side‑effects (fetch) are quarantined in useEffect, ensuring no duplicate requests on re‑render.

Incorrect Example

function RandomJokeImpure() {
  const [joke, setJoke] = useState<string | null>(null);

  // ❌ Fetching during render—runs every render in Dev Strict Mode.
  if (!joke) {
    fetch("https://api.chucknorris.io/jokes/random")
      .then((res) => res.json())
      .then((data) => setJoke(data.value));
  }

  return (
    <section className="rounded-xl border p-4">
      {joke ?? "Loading a hilarious joke…"}
    </section>
  );
}
// In development, Strict Mode double‑invokes render → two fetches. In
// concurrent mode, React may start and abandon renders, leaking requests.

Key Takeaways

  • Render = math, nothing else: Treat it like a pure function—no mutations, I/O, or timers.
  • Side‑effects live in Hooks: Use useEffect (or useLayoutEffect for layout‑critical work) to perform asynchronous calls, subscriptions, or DOM writes.
  • Avoid state‑updates‑inside‑render: Calling setState during render triggers an immediate re‑render loop.
  • Strict Mode will punish impurity: React dev tooling intentionally double‑calls render to surface hidden side‑effects—embrace it as a safety net.
  • Purity unlocks concurrency: Clean render functions let React pause, resume, or replay work without causing logical fallout.

7. Hooks Extraction

Factor reusable logic into custom Hooks; expose plain values and callbacks, not JSX.

Separates behavior from presentation and encourages composition.

Copy‑pasting the same useEffect, event listeners, or form state across components clutters files and invites bugs when one copy drifts. Custom Hooks let you package that behavior once and share it everywhere—without tying it to any specific UI. Beginners sometimes build a “hook” that actually returns JSX or leave the logic inline, missing out on composition and testability.

Correct Example

// useWindowSize.ts
import { useEffect, useState } from "react";

export function useWindowSize() {
  const [size, setSize] = useState<[number, number]>([
    window.innerWidth,
    window.innerHeight,
  ]);

  useEffect(() => {
    const onResize = () => setSize([window.innerWidth, window.innerHeight]);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  return size; // <-- data only, no JSX!
}
// ResponsiveCard.tsx
import { useWindowSize } from "./useWindowSize";

export default function ResponsiveCard({
  children,
}: {
  children: React.ReactNode;
}) {
  const [w] = useWindowSize();
  const isCompact = w < 640;

  return (
    <div className={isCompact ? "p-4" : "p-8 shadow-lg rounded-2xl"}>
      {children}
    </div>
  );
}

What’s happening?

  • useWindowSize holds all resize logic, returns only numbers.
  • Any component can now respond to window size without duplicating listeners or DOM code.

Incorrect Example

// "Hook" that mixes UI with logic — hard to reuse.
function useWindowSizeComponent() {
  const [size, setSize] = useState<[number, number]>([
    window.innerWidth,
    window.innerHeight,
  ]);

  useEffect(() => {
    const onResize = () => setSize([window.innerWidth, window.innerHeight]);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  // ❌ Returning JSX breaks single‑responsibility
  return (
    <p className="text-xs text-gray-500">
      Window: {size[0]}×{size[1]}
    </p>
  );
}

// Every consumer ends up stuck with this paragraph—or re‑implements the logic.
// Logic duplicated inline in multiple components.
function HeroBanner() {
  const [w] = useState(window.innerWidth); // ❌ duplicate state
  useEffect(() => {
    const onResize = () => setW(window.innerWidth); // ❌ duplicate listener
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  /* render … */
}

Key Takeaways

  • Custom Hook = behavior package: Encapsulate state + side effects, return data and callbacks—never JSX.
  • Reuse beats copy‑paste: Centralizing logic avoids drift and simplifies bug fixes.
  • Composable building blocks: Hooks can call other hooks, letting you layer behavior without giant components.
  • Test in isolation: Logic‑only hooks can be unit‑tested without rendering DOM.
  • Naming convention: Always start with use so ESLint/react‑hooks can verify correct usage.

8. Effect Isolation

Use `useEffect` (or `useLayoutEffect` only when layout‑critical) solely for side‑effects; keep calculations inside render or `useMemo`.

Prevents unnecessary asynchronous work and avoids tearing.

Copy‑pasting the same useEffect, event listeners, or form state across components clutters files and invites bugs when one copy drifts. Custom Hooks let you package that behavior once and share it everywhere—without tying it to any specific UI. Beginners sometimes build a “hook” that actually returns JSX or leave the logic inline, missing out on composition and testability.

Correct Example

// useWindowSize.ts
import { useEffect, useState } from "react";

export function useWindowSize() {
  const [size, setSize] = useState<[number, number]>([
    window.innerWidth,
    window.innerHeight,
  ]);

  useEffect(() => {
    const onResize = () => setSize([window.innerWidth, window.innerHeight]);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  return size; // <-- data only, no JSX!
}
// ResponsiveCard.tsx
import { useWindowSize } from "./useWindowSize";

export default function ResponsiveCard({
  children,
}: {
  children: React.ReactNode;
}) {
  const [w] = useWindowSize();
  const isCompact = w < 640;

  return (
    <div className={isCompact ? "p-4" : "p-8 shadow-lg rounded-2xl"}>
      {children}
    </div>
  );
}

What’s happening?

  • useWindowSize holds all resize logic, returns only numbers.
  • Any component can now respond to window size without duplicating listeners or DOM code.

Incorrect Example

// "Hook" that mixes UI with logic — hard to reuse.
function useWindowSizeComponent() {
  const [size, setSize] = useState<[number, number]>([
    window.innerWidth,
    window.innerHeight,
  ]);

  useEffect(() => {
    const onResize = () => setSize([window.innerWidth, window.innerHeight]);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  // ❌ Returning JSX breaks single‑responsibility
  return (
    <p className="text-xs text-gray-500">
      Window: {size[0]}×{size[1]}
    </p>
  );
}

// Every consumer ends up stuck with this paragraph—or re‑implements the logic.
// Logic duplicated inline in multiple components.
function HeroBanner() {
  const [w] = useState(window.innerWidth); // ❌ duplicate state
  useEffect(() => {
    const onResize = () => setW(window.innerWidth); // ❌ duplicate listener
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  /* render … */
}

Key Takeaways

  • Custom Hook = behavior package: Encapsulate state + side effects, return data and callbacks—never JSX.
  • Reuse beats copy‑paste: Centralizing logic avoids drift and simplifies bug fixes.
  • Composable building blocks: Hooks can call other hooks, letting you layer behavior without giant components.
  • Test in isolation: Logic‑only hooks can be unit‑tested without rendering DOM.
  • Naming convention: Always start with use so ESLint/react‑hooks can verify correct usage.

9. Data Fetching Layering

Fetch remote data at the route or page level (or in a dedicated data layer) and pass it downward; avoid fetching deep in leaf widgets.

Centralises loading/error handling and enables streaming / Suspense.

If every little widget fires its own network request, you get a waterfall of spinners, duplicated calls, and tangled loading/error states. Worse, those leaf‑level fetches are hard to coordinate—try showing a single “Loading…” skeleton when five children fetch independently! Centralising data‑loading in a parent (route, page, or data layer) lets you fetch once, handle errors in one place, and stream structured data to child components.

Correct Example

// UsersPage.tsx  – page‑level component controls data fetch & state
import { useEffect, useState } from "react";
import UsersTable from "./UsersTable";

type User = { id: number; name: string; email: string };

export default function UsersPage() {
  const [users, setUsers] = useState<User[]>([]);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/users")
      .then((res) => (res.ok ? res.json() : Promise.reject(res)))
      .then(setUsers)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>Loading users…</p>;
  if (error) return <p className="text-red-600">Failed: {error.message}</p>;

  return <UsersTable users={users} />;
}
// UsersTable.tsx  – presentational; receives ready‑to‑render data
type User = { id: number; name: string; email: string };

export default function UsersTable({ users }: { users: User[] }) {
  return (
    <table className="w-full border-collapse">
      <thead>
        <tr className="bg-gray-100 text-left">
          <th className="p-2">Name</th>
          <th className="p-2">Email</th>
        </tr>
      </thead>
      <tbody>
        {users.map((u) => (
          <tr key={u.id} className="border-t">
            <td className="p-2">{u.name}</td>
            <td className="p-2">{u.email}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

What’s happening? UsersPage fetches once, manages loading/error UI, then hands clean data to UsersTable, which remains fetch‑agnostic and easy to reuse, test, or server‑render.

Incorrect Example

// UsersTableFetchesItself.tsx  – every table instance fires a request
import { useEffect, useState } from "react";

export default function UsersTableFetchesItself() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/users") // ❌ leaf‑level fetch
      .then((res) => res.json())
      .then(setUsers)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>Loading…</p>;

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

// Used twice on the same page? Two identical network calls run.
// Want a single skeleton? You now need cross‑component coordination.

Key Takeaways

  • Single source of data: Fetch at the highest common parent (route, page, or dedicated provider) to avoid duplicate requests.
  • Unified loading/error handling: One spinner and one error boundary are clearer than many scattered ones.
  • Leaf components stay pure: Children focus on presentation, making them simpler to test, memoise, and reuse offline.
  • Enables streaming and Suspense: Central control lets you adopt React Suspense or server streaming without rewriting every widget.
  • Performance wins: Fewer redundant calls, better caching opportunities, and less “jank” from staggered spinners.

10. Context Minimal

Introduce React Context only for truly global, read‑heavy data; never for high‑frequency changing state.

Limits blanket re‑renders and keeps dependency graph clear.

Because every Context update forces all subscribed components to re‑render, stuffing fast‑changing values (typing, mouse position, live timers) into Context can tank performance. Teams sometimes reach for Context as a shortcut to avoid prop‑drilling and end up with an app that re‑renders on every keystroke. Context shines for information that is needed everywhere and changes seldom—think theme, locale, or the current authenticated user.

Correct Example

// ThemeContext.tsx
import { createContext, ReactNode, useContext, useState } from "react";

type Theme = "light" | "dark";
const ThemeContext = createContext<Theme>("light");

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light"); // infrequent changes

  return (
    <ThemeContext.Provider value={theme}>
      <button
        onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
        className="fixed right-4 top-4 rounded bg-gray-800 px-3 py-1 text-white"
      >
        Toggle Theme
      </button>
      {children}
    </ThemeContext.Provider>
  );
}

export const useTheme = () => useContext(ThemeContext);
// Button.tsx — consumes Context without prop‑drilling
import { useTheme } from "./ThemeContext";

export default function Button({ children }: { children: React.ReactNode }) {
  const theme = useTheme();
  const style =
    theme === "dark"
      ? "rounded bg-gray-700 px-4 py-2 text-white"
      : "rounded bg-gray-200 px-4 py-2 text-gray-900";

  return <button className={style}>{children}</button>;
}

Why this is good Theme toggles are rare, so re‑rendering every themed component is cheap and predictable.

Incorrect Example

// SearchContext.tsx — puts a per‑keystroke value in Context
import { createContext, ReactNode, useContext, useState } from "react";

const SearchContext = createContext<string>("");

export function SearchProvider({ children }: { children: ReactNode }) {
  const [query, setQuery] = useState(""); // ⚠️ updates every keypress
  return (
    <SearchContext.Provider value={query}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search…"
        className="mb-4 w-full rounded border p-2"
      />
      {children}
    </SearchContext.Provider>
  );
}

export const useSearch = () => useContext(SearchContext);
function ProductList({ products }: { products: Product[] }) {
  const query = useSearch(); // re‑renders constantly
  const filtered = products.filter((p) =>
    p.name.toLowerCase().includes(query.toLowerCase())
  );
  /* render list */
}
// Every character typed re‑renders ProductList and ANY other consumer,
// even ones that don’t care about the search query.

Key Takeaways

  • Reserve Context for global, rarely changing values (theme, locale, auth user, feature flags).
  • Keep rapid updates local—pass them down via props or use a state library that slices updates to subscribers.
  • Changing Context = blanket re‑render: Profile before promoting state to Context.
  • **If you feel Context overuse creeping in, reach for custom hooks, prop composition, or specialized stores instead.

11. Code Splitting

Wrap non‑critical modules in `React.lazy` + `Suspense`; prefer route‑level boundaries.

Improves first‑paint times without complicating component logic.

Every import you add bundles more JavaScript that the browser must parse before your app can paint. If you always load everything up front—charts, admin panels, rarely‑used modals—first‑time users wait on code they never touch. Conversely, if you sprinkle React.lazy around every little component you can create a waterfall of tiny requests that actually slows things down. Good code splitting strikes a balance: defer non‑essential chunks (whole routes, big libraries) behind a single Suspense boundary so the shell loads fast and the user sees meaningful UI immediately.

Correct Example

// App.tsx — route‑level code splitting
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Suspense, lazy } from "react";
import HomePage from "./pages/HomePage";

const AdminPage = lazy(() => import("./pages/AdminPage")); // heavy chunk
const ReportsPage = lazy(() => import("./pages/ReportsPage")); // heavy chart lib

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<p className="p-8">Loading page…</p>}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/admin/*" element={<AdminPage />} />
          <Route path="/reports/*" element={<ReportsPage />} />
          <Route path="*" element={<Navigate to="/" replace />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Why this is good

  • Only the home bundle ships on first load; admin and reports code download the moment a user navigates there.
  • One central Suspense fallback handles loading UI for all lazy routes.

Incorrect Example A — monolithic bundle

// App.tsx — everything imported eagerly
import HomePage from "./pages/HomePage";
import AdminPage from "./pages/AdminPage"; // ⬅ loads React‑Table, rich‑text editor…
import ReportsPage from "./pages/ReportsPage"; // ⬅ loads charting library

export default function App() {
  return (
    <BrowserRouter>
      {/* first paint delayed by 500 KB+ of admin/report code the user may never see */}

    </BrowserRouter>
  );
}

Incorrect Example B — over‑splitting waterfall

// HeavyChart.tsx split into too many micro‑chunks
const Axis = lazy(() => import("./HeavyChart/Axis"));
const Legend = lazy(() => import("./HeavyChart/Legend"));
const Bars = lazy(() => import("./HeavyChart/Bars"));
/* dozens more… */

export default function HeavyChart() {
  return (
    <Suspense fallback={<p>Chart loading…</p>}>
      <Axis />
      <Legend />
      <Bars /> {/* → dozens of small network requests */}
    </Suspense>
  );
}
// Each sub‑component triggers its own HTTP request; blocking until
// *all* arrive often feels slower than loading one consolidated chunk.

Key Takeaways

  • Split by user journey, not by every component: Routes, feature panels, and big third‑party libs are prime candidates.
  • Use one Suspense boundary per split group: Keeps loading UI simple and avoids nested spinners.
  • Bundle size vs. request count: Fewer, moderately sized chunks typically beat dozens of tiny files.
  • Measure! Verify impact with Webpack Bundle Analyzer or Chrome’s Coverage tab instead of guessing.
  • Server‑side / streaming friendly: Route‑level code splitting pairs nicely with React Server Components and HTTP streaming for even faster perceived loads.

12. Memoization On Demand

Apply `React.memo`, `useMemo`, and `useCallback` only when profiling identifies a real performance issue.

Avoids stale closures and premature optimisation overhead.

Premature optimization is a classic rabbit hole. It’s tempting to sprinkle useMemo and wrap every component in React.memo “just in case.” The cost? Extra code to read, risk of stale closures, and more memory usage—sometimes even slower renders when dependencies change. Memoization shines when a calculation is expensive or a prop‑drilling chain triggers costly re‑renders and you can prove it in React DevTools. Otherwise, the safest—and fastest—code is the simplest one.

Correct Example

import { useMemo } from "react";

type Order = { id: number; total: number; date: string };

function useOrderStats(orders: Order[]) {
  // Heavy calculation (e.g., thousands of orders) — legit candidate for useMemo
  return useMemo(() => {
    const totals = orders.map((o) => o.total);
    const sum = totals.reduce((s, t) => s + t, 0);
    return {
      count: orders.length,
      average: sum / orders.length || 0,
      max: Math.max(...totals),
    };
  }, [orders]);
}

export default function Dashboard({ orders }: { orders: Order[] }) {
  const stats = useOrderStats(orders);

  return (
    <section className="rounded-xl border p-6">
      <h2 className="text-lg font-semibold">Order Stats</h2>
      <p>Total orders: {stats.count}</p>
      <p>Average value: ${stats.average.toFixed(2)}</p>
      <p>Largest order: ${stats.max.toFixed(2)}</p>
    </section>
  );
}

Why this is good

  • Measured need: The calculation scales with data size. Developers saw a slowdown in profiling, then wrapped it in useMemo.
  • Clean deps: Only orders matter; memo stays valid until the list truly changes.

Incorrect Example

// Over‑memoization everywhere
const FancyButton = React.memo(function FancyButton(props: { label: string }) {
  console.log("FancyButton render"); // still logs on every parent re-render
  return (
    <button className="rounded bg-blue-600 px-4 py-2 text-white">
      {props.label}
    </button>
  );
});

export default function Form() {
  const [name, setName] = useState("");

  // ❌ unnecessary useCallback: setName already stable from React
  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value),
    []
  );

  // ❌ useMemo wraps a trivial string concat
  const greeting = useMemo(() => `Hello, ${name}!`, [name]);

  return (
    <>
      <input value={name} onChange={handleChange} className="border p-2" />
      <p>{greeting}</p> {/* useless memoization */}
      <FancyButton label="Submit" />
      {/* memoized component still re-renders because props change? none! */}
    </>
  );
}
// Outcome: more code, zero performance gain, and potential stale-bug
// risks if dependencies are forgotten later.

Key Takeaways

  • Start with profiling: Reach for memoization only after DevTools shows a real hotspot.
  • Expensive, pure calculations only: Heavy loops, deep object transformations, or large lists—not string templates or tiny math.
  • Keep dependency arrays exact: Missing deps = stale values; extra deps = memo never hits.
  • React.memo ≠ free pass: Components inside still re‑render if shallow‑compared props change or context updates.
  • Less code, fewer bugs: Default to plain functions; optimise where it measurably matters.

13. Type Safety

Write components in TypeScript with `strict` mode on; export precise prop and state types.

Prevents runtime bugs and enables richer IDE/autocomplete support.

TypeScript isn’t just a nicer autocompletion engine—it’s a contract that catches bugs before they hit the browser. But the safety net only works when you keep the holes small. Turning off strict (or sprinkling any everywhere) lets subtle null, undefined, or shape‑mismatch errors slip through, especially as a codebase grows and contributors change APIs. Treating types as first‑class keeps prop boundaries honest, documents intent, and lets IDEs refactor code confidently.

Correct Example

// ProductCard.tsx – fully typed, strict‑mode friendly
import { ReactNode } from "react";

export type Product = {
  id: string;
  name: string;
  priceCents: number;
  imageUrl: string;
};

interface Props {
  product: Product;
  children?: ReactNode;
}

export default function ProductCard({ product, children }: Props) {
  const price = (product.priceCents / 100).toFixed(2);

  return (
    <article className="rounded-xl border p-4 shadow-sm">
      <img
        src={product.imageUrl}
        alt={product.name}
        className="mb-3 h-40 w-full object-cover"
      />
      <h3 className="text-lg font-semibold">{product.name}</h3>
      <p className="mb-4 text-gray-700">${price}</p>
      {children}
    </article>
  );
}

Why this is good

  • Explicit Product type documents data contract across the app.
  • Optional children is typed as ReactNode—no silent undefined errors.
  • strict: true (in tsconfig.json) ensures missing props or null checks fail at compile time.

Incorrect Example

// ProductCardLoose.tsx – loose typing, no strict mode
export default function ProductCardLoose({ product }) {
  // 🤔 product is `any`
  // Implicit any: could be null, undefined, or misspelled keys
  const price = (product.price / 100).toFixed(2); // runtime crash if price undefined

  return (
    <div className="card">
      <img src={product.img} /> {/* typo: img vs imageUrl */}
      <h3>{product.name}</h3>
      <p>${price}</p>
    </div>
  );
}

// Somewhere else …
<ProductCardLoose product={{ id: 42, title: "Banana" }} />; // passes compile
// Renders undefined props, mis‑named keys, potential NaN price

Key Takeaways

  • Turn on strict: strict: true (and noImplicitAny) forces you to handle nullability and missing props.
  • Define reusable domain types: Central Product, User, etc. prevent shape drift and duplicated interfaces.
  • Never use any as a shortcut: Reach for union, generic, or utility types (Partial, Pick) instead.
  • Prop & state typing = self‑docs: A teammate (or future you) can read prop interfaces faster than scrolling comments.
  • Safer refactors: IDE rename + type‑checking flag mismatches instantly across the whole codebase.

14. Error Boundaries

Place Error Boundaries at app shell and around separately‑loaded chunks; log and gracefully degrade UI.

Contains failures and enhances resilience.

15. Accessibility First

Default to semantic HTML elements, ARIA roles only when needed, and keyboard operability for all interactive components.

Makes apps usable by everyone and meets WCAG standards.

16. Test User Behavior

Write tests at the behavior level using React Testing Library; avoid shallow rendering.

Tests reflect real user interactions, not internal implementation.

17. File Structure Feature First

Organise files by feature/domain (not by type), grouping components, hooks, tests, and styles together.

Reduces cross‑tree hopping and clarifies ownership.

18. Styles Colocated

Co‑locate styling (CSS Modules, CSS‑in‑JS, or Tailwind classes) with components; avoid global cascade leakage.

Improves maintainability and prevents style collisions.

19. Naming Consistent

Use PascalCase for components, camelCase for hooks and variables, and kebab‑case for filenames.

Creates a predictable, lintable codebase.