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.
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>
);
}
// 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.
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.
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>
);
}
// 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.”
useMemo
when the calculation is heavy.useState
is a synchronization liability—eliminate the unnecessary ones.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.
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.
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.
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.
// 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.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.
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.
// 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?
useState
) and side‑effects (useEffect
) live in a plain function—no this
, no bindings.// 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.
this
, lifecycle method maze, or manual bindings—just plain JavaScript functions.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.
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.
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.
useEffect
(or useLayoutEffect
for layout‑critical work) to perform asynchronous calls, subscriptions, or DOM writes.setState
during render triggers an immediate re‑render loop.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.
// 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.// "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 … */
}
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.
// 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.// "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 … */
}
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.
// 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.
// 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.
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.
// 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.
// 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.
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.
// 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
Suspense
fallback handles loading UI for all lazy routes.// 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>
);
}
// 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.
Suspense
boundary per split group: Keeps loading UI simple and avoids nested spinners.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.
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
useMemo
.orders
matter; memo stays valid until the list truly changes.// 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.
React.memo
≠ free pass: Components inside still re‑render if shallow‑compared props change or context updates.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.
// 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
Product
type documents data contract across the app.children
is typed as ReactNode
—no silent undefined
errors.strict: true
(in tsconfig.json
) ensures missing props or null checks fail at compile time.// 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
strict
: strict: true
(and noImplicitAny
) forces you to handle nullability and missing props.Product
, User
, etc. prevent shape drift and duplicated interfaces.any
as a shortcut: Reach for union, generic, or utility types (Partial
, Pick
) instead.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.