โ๏ธ
React
React hooks, patterns, performance, context, refs and modern best practices
Component Fundamentals
Function components, props, JSX patterns and children
tsxยทFunction component with typed props
import type { ReactNode, CSSProperties } from "react";
interface ButtonProps {
label: string;
variant?: "primary" | "secondary" | "ghost";
disabled?: boolean;
onClick?: () => void;
children?: ReactNode;
style?: CSSProperties;
}
export function Button({
label,
variant = "primary",
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button
className={`btn btn--${variant}`}
disabled={disabled}
onClick={onClick}
>
{children ?? label}
</button>
);
}tsxยทJSX patterns โ conditionals and lists
function Feed({ items, isLoading, error, currentUser }) {
// Early returns keep JSX clean
if (error) return <ErrorBanner message={error.message} />;
if (isLoading) return <Spinner />;
if (!items.length) return <EmptyState />;
return (
<ul>
{/* Conditional rendering */}
{currentUser && <li>Welcome, {currentUser.name}!</li>}
{/* Ternary */}
{items.length > 0 ? (
items.map((item) => (
// key must be stable, unique, not the array index if list reorders
<li key={item.id}>
<span>{item.title}</span>
{item.isPinned && <PinIcon />}
</li>
))
) : (
<li>No items</li>
)}
{/* Fragment shorthand to avoid extra div */}
<>
<li>A</li>
<li>B</li>
</>
</ul>
);
}tsxยทChildren patterns and render props
import type { ReactNode, ComponentProps } from "react";
// Accept any renderable children
function Card({ children, title }: { children: ReactNode; title: string }) {
return (
<div className="card">
<h2>{title}</h2>
{children}
</div>
);
}
// Spread native element props (polymorphic component)
interface TextProps extends ComponentProps<"p"> {
as?: "p" | "span" | "div";
size?: "sm" | "md" | "lg";
}
function Text({ as: Tag = "p", size = "md", className = "", ...rest }: TextProps) {
return <Tag className={`text text--${size} ${className}`} {...rest} />;
}
// Render prop
function Toggle({ render }: { render: (on: boolean, toggle: () => void) => ReactNode }) {
const [on, setOn] = React.useState(false);
return <>{render(on, () => setOn((prev) => !prev))}</>;
}
// Usage:
// <Toggle render={(on, toggle) => (
// <button onClick={toggle}>{on ? "ON" : "OFF"}</button>
// )} />Core Hooks
useState, useEffect, useReducer and useId
tsxยทuseState โ state and updater patterns
import { useState } from "react";
// Primitive state
const [count, setCount] = useState(0);
const [name, setName] = useState("");
const [open, setOpen] = useState(false);
// Functional update โ use when new state depends on old state
setCount((prev) => prev + 1);
setOpen((prev) => !prev);
// Object state โ always spread to avoid losing fields
const [form, setForm] = useState({ email: "", password: "" });
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
// Lazy initializer โ runs once, avoids expensive computation on every render
const [data, setData] = useState(() => JSON.parse(localStorage.getItem("data") ?? "null"));
// Derived state โ compute from existing state, don't duplicate
const [items, setItems] = useState<string[]>([]);
const isEmpty = items.length === 0; // derived โ no separate useState
const sorted = [...items].sort(); // derived โ compute in rendertsxยทuseEffect โ data fetching, subscriptions, cleanup
import { useState, useEffect } from "react";
// Run after every render
useEffect(() => { document.title = `${count} items`; });
// Run once on mount
useEffect(() => {
console.log("mounted");
return () => console.log("unmounted"); // cleanup runs on unmount
}, []);
// Run when deps change
useEffect(() => {
if (!userId) return;
let cancelled = false;
async function fetchUser() {
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
if (!cancelled) setUser(data);
}
fetchUser();
return () => { cancelled = true; }; // cancel stale updates
}, [userId]);
// Event listener with cleanup
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// Rules: don't ignore the exhaustive-deps lint warning
// If a dep causes an infinite loop, check if the dep is stabletsxยทuseReducer โ complex state logic
import { useReducer } from "react";
type State = { count: number; step: number };
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "setStep"; payload: number }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment": return { ...state, count: state.count + state.step };
case "decrement": return { ...state, count: state.count - state.step };
case "setStep": return { ...state, step: action.payload };
case "reset": return { count: 0, step: 1 };
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
return (
<>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
<input
type="number"
value={state.step}
onChange={(e) => dispatch({ type: "setStep", payload: Number(e.target.value) })}
/>
</>
);
}Refs
useRef for DOM access, mutable values and forwardRef
tsxยทuseRef โ DOM access and mutable values
import { useRef, useEffect } from "react";
// DOM ref โ auto-assigned when element mounts
function SearchInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // focus on mount
}, []);
return <input ref={inputRef} type="search" />;
}
// Mutable value โ survives re-renders, changing it does NOT trigger re-render
function Timer() {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [seconds, setSeconds] = useState(0);
function start() {
intervalRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
}
function stop() {
if (intervalRef.current) clearInterval(intervalRef.current);
}
return <>{seconds}s <button onClick={start}>Start</button> <button onClick={stop}>Stop</button></>;
}
// Store previous value
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>(undefined);
useEffect(() => { ref.current = value; });
return ref.current;
}tsxยทforwardRef and useImperativeHandle
import { forwardRef, useRef, useImperativeHandle } from "react";
// forwardRef โ pass ref through to a DOM element
const FancyInput = forwardRef<HTMLInputElement, { label: string }>(
({ label }, ref) => (
<label>
{label}
<input ref={ref} />
</label>
)
);
FancyInput.displayName = "FancyInput";
// Usage
const inputRef = useRef<HTMLInputElement>(null);
<FancyInput ref={inputRef} label="Email" />
inputRef.current?.focus();
// useImperativeHandle โ expose custom API instead of raw DOM node
interface DialogHandle {
open: () => void;
close: () => void;
}
const Modal = forwardRef<DialogHandle, { children: ReactNode }>(
({ children }, ref) => {
const [visible, setVisible] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setVisible(true),
close: () => setVisible(false),
}));
if (!visible) return null;
return <div className="modal">{children}</div>;
}
);
// Usage
const modalRef = useRef<DialogHandle>(null);
modalRef.current?.open();Performance
memo, useMemo, useCallback and useTransition
tsxยทmemo โ skip re-renders when props unchanged
import { memo } from "react";
// Wrap component to skip re-render if props are shallowly equal
const Avatar = memo(function Avatar({ user }: { user: User }) {
return <img src={user.avatarUrl} alt={user.name} />;
});
// Custom comparison function (use sparingly)
const Row = memo(
function Row({ item }: { item: Item }) {
return <tr><td>{item.name}</td></tr>;
},
(prevProps, nextProps) => prevProps.item.id === nextProps.item.id
);
// memo only helps when:
// 1. The component renders often
// 2. Props genuinely don't change between renders
// 3. The render is expensive
// Don't wrap every component โ profile firsttsxยทuseMemo and useCallback
import { useMemo, useCallback } from "react";
// useMemo โ memoize an expensive computed value
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items] // only re-sort when items changes
);
const filteredUsers = useMemo(
() => users.filter((u) => u.role === activeRole && u.name.includes(search)),
[users, activeRole, search]
);
// useCallback โ memoize a function (stable reference across renders)
// Needed when passing callbacks to memo'd children or as useEffect deps
const handleDelete = useCallback(
async (id: string) => {
await deleteItem(id);
setItems((prev) => prev.filter((item) => item.id !== id));
},
[deleteItem] // recreate only when deleteItem changes
);
// When NOT to memoize:
// - Simple calculations (string concat, arithmetic) โ overhead isn't worth it
// - Components that always re-render anyway (parent isn't memo'd)
// - Values that change every render (new object literals as deps)tsxยทuseTransition and useDeferredValue
import { useState, useTransition, useDeferredValue } from "react";
// useTransition โ mark state updates as non-urgent
// UI stays responsive; React can interrupt the transition for urgent updates
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // urgent โ update input immediately
startTransition(() => {
// non-urgent โ can be interrupted by typing
setResults(expensiveSearch(e.target.value));
});
}
return (
<>
<input value={query} onChange={handleInput} />
{isPending && <Spinner />}
<Results data={results} />
</>
);
}
// useDeferredValue โ defer an expensive re-render without controlling the update
function FilteredList({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
// deferredQuery lags behind query โ list re-renders with lower priority
const filtered = useMemo(() => expensiveFilter(deferredQuery), [deferredQuery]);
return <List items={filtered} />;
}Context
Share state across the tree without prop drilling
tsxยทContext with typed hook and provider
import { createContext, useContext, useState, type ReactNode } from "react";
interface AuthContextValue {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
// Custom hook โ throws if used outside provider
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
async function login(credentials: Credentials) {
const res = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify(credentials),
});
const data = await res.json();
setUser(data.user);
}
function logout() {
setUser(null);
fetch("/api/auth/logout", { method: "POST" });
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}tsxยทSplitting context to prevent unnecessary re-renders
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
// Split STATE and DISPATCH into separate contexts
// Components that only dispatch never re-render when state changes
const CountStateContext = createContext<number>(0);
const CountDispatchContext = createContext<{
increment: () => void;
decrement: () => void;
} | null>(null);
export function CounterProvider({ children }: { children: ReactNode }) {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = useCallback(() => setCount((c) => c - 1), []);
return (
<CountStateContext.Provider value={count}>
<CountDispatchContext.Provider value={{ increment, decrement }}>
{children}
</CountDispatchContext.Provider>
</CountStateContext.Provider>
);
}
export const useCount = () => useContext(CountStateContext);
export const useCountDispatch = () => {
const ctx = useContext(CountDispatchContext);
if (!ctx) throw new Error("useCountDispatch must be inside CounterProvider");
return ctx;
};Custom Hooks
Reusable logic extracted into custom hooks
tsxยทuseFetch โ data fetching with loading and error state
import { useState, useEffect, useCallback } from "react";
interface FetchState<T> {
data: T | null;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
export function useFetch<T>(url: string): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((json) => { if (!cancelled) setData(json); })
.catch((err) => { if (!cancelled) setError(err); })
.finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; };
}, [url, tick]);
const refetch = useCallback(() => setTick((t) => t + 1), []);
return { data, isLoading, error, refetch };
}
// Usage
const { data: user, isLoading, error } = useFetch<User>(`/api/users/${id}`);tsxยทuseLocalStorage, useDebounce, useMediaQuery
import { useState, useEffect, useCallback } from "react";
// useLocalStorage
export function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
} catch { return initial; }
});
const set = useCallback((next: T | ((prev: T) => T)) => {
setValue((prev) => {
const resolved = typeof next === "function" ? (next as (p: T) => T)(prev) : next;
localStorage.setItem(key, JSON.stringify(resolved));
return resolved;
});
}, [key]);
return [value, set] as const;
}
// useDebounce
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// useMediaQuery
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mq = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [query]);
return matches;
}
// Usage
const isMobile = useMediaQuery("(max-width: 768px)");
const [theme, setTheme] = useLocalStorage("theme", "light");
const debouncedSearch = useDebounce(searchQuery, 300);tsxยทuseEventListener, useOnClickOutside, useLockBodyScroll
import { useEffect, useRef, type RefObject } from "react";
// useEventListener
export function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: (e: WindowEventMap[K]) => void,
element: EventTarget = window,
) {
const savedHandler = useRef(handler);
useEffect(() => { savedHandler.current = handler; }, [handler]);
useEffect(() => {
const listener = (e: Event) => savedHandler.current(e as WindowEventMap[K]);
element.addEventListener(event, listener);
return () => element.removeEventListener(event, listener);
}, [event, element]);
}
// useOnClickOutside
export function useOnClickOutside<T extends HTMLElement>(
ref: RefObject<T | null>,
handler: (e: MouseEvent | TouchEvent) => void,
) {
useEventListener("mousedown", (e) => {
if (!ref.current || ref.current.contains(e.target as Node)) return;
handler(e);
});
}
// useLockBodyScroll โ prevent background scroll when modal is open
export function useLockBodyScroll(locked: boolean) {
useEffect(() => {
if (!locked) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = original; };
}, [locked]);
}
// Usage
const menuRef = useRef<HTMLDivElement>(null);
useOnClickOutside(menuRef, () => setOpen(false));
useLockBodyScroll(isModalOpen);Patterns
Error boundaries, Suspense, portals, compound components
tsxยทError Boundary
import { Component, type ReactNode, type ErrorInfo } from "react";
interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { hasError: boolean; error: Error | null; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Uncaught error:", error, info.componentStack);
// reportErrorToSentry(error, info);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div role="alert">
<p>Something went wrong.</p>
<pre>{this.state.error?.message}</pre>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
// Usage
<ErrorBoundary fallback={<p>Failed to load widget.</p>}>
<ExpensiveWidget />
</ErrorBoundary>tsxยทSuspense and lazy loading
import { Suspense, lazy } from "react";
// Code-split a component โ bundle is loaded on demand
const Chart = lazy(() => import("./Chart"));
const UserModal = lazy(() => import("./UserModal"));
// Wrap lazy components in Suspense
function Dashboard() {
return (
<Suspense fallback={<Skeleton />}>
<Chart data={data} />
</Suspense>
);
}
// Nested Suspense โ different fallbacks for different parts
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Header />
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={null}>
<SidePanel />
</Suspense>
</Suspense>
);
}tsxยทPortal โ render outside parent DOM
import { createPortal } from "react-dom";
import { useState, useEffect, useRef, type ReactNode } from "react";
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}) {
// Trap focus and close on Escape
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Renders into document.body, outside the React tree DOM-wise
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body
);
}tsxยทCompound component pattern
import { createContext, useContext, useState, type ReactNode } from "react";
// Compound components share state implicitly via context
const TabsContext = createContext<{
active: string;
setActive: (id: string) => void;
} | null>(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Must be inside <Tabs>");
return ctx;
}
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [active, setActive] = useState(defaultTab);
return (
<TabsContext.Provider value={{ active, setActive }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist" className="tab-list">{children}</div>;
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { active, setActive } = useTabs();
return (
<button
role="tab"
aria-selected={active === id}
onClick={() => setActive(id)}
>
{children}
</button>
);
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const { active } = useTabs();
if (active !== id) return null;
return <div role="tabpanel">{children}</div>;
}
// Attach as static properties
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Usage โ clean, readable, no prop drilling
// <Tabs defaultTab="profile">
// <Tabs.List>
// <Tabs.Tab id="profile">Profile</Tabs.Tab>
// <Tabs.Tab id="settings">Settings</Tabs.Tab>
// </Tabs.List>
// <Tabs.Panel id="profile"><ProfileForm /></Tabs.Panel>
// <Tabs.Panel id="settings"><SettingsForm /></Tabs.Panel>
// </Tabs>Common Mistakes & Fixes
Stale closures, infinite loops, key misuse and batching
tsxยทStale closure in useEffect
// โ Stale closure โ count is always 0 inside the interval
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count captured from first render
}, 1000);
return () => clearInterval(id);
}, []); // empty deps โ never re-runs
// โ
Fix 1: functional update โ no stale closure needed
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1); // always uses current value
}, 1000);
return () => clearInterval(id);
}, []);
// โ
Fix 2: add dep (effect re-runs each time count changes)
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
// โ
Fix 3: useRef for stable mutable value
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);tsxยทInfinite useEffect loops and object deps
// โ Infinite loop โ new object created every render triggers effect
useEffect(() => {
fetchData(options);
}, [options]); // options = { page: 1 } re-created each render
// โ
Fix 1: primitive deps instead of object
useEffect(() => {
fetchData({ page, sort });
}, [page, sort]);
// โ
Fix 2: memoize the object
const options = useMemo(() => ({ page, sort }), [page, sort]);
useEffect(() => { fetchData(options); }, [options]);
// โ Infinite loop โ setState in useEffect without dep guard
useEffect(() => {
setData(processData(rawData)); // triggers re-render โ effect runs โ repeat
});
// โ
Fix: add dep array
useEffect(() => {
setData(processData(rawData));
}, [rawData]);
// โ Function recreated every render causes child re-render storm
<Child onUpdate={() => setCount((c) => c + 1)} />
// โ
Fix: stable reference with useCallback
const handleUpdate = useCallback(() => setCount((c) => c + 1), []);
<Child onUpdate={handleUpdate} />tsxยทKey prop mistakes and state reset
// โ Using index as key when list can reorder
items.map((item, index) => <Row key={index} item={item} />);
// React reuses DOM nodes โ state in Row gets associated with wrong item
// โ
Use stable, unique ID from data
items.map((item) => <Row key={item.id} item={item} />);
// key as a reset mechanism โ change key to fully reset child state
// Useful when you want to reset a form or component on prop change
<UserForm key={userId} userId={userId} />
// Changing userId causes React to unmount old component and mount fresh one
// โ State initialised from prop โ stale after prop changes
function Input({ defaultValue }) {
const [value, setValue] = useState(defaultValue); // only uses initial render value
...
}
// โ
Use key to reset
<Input key={userId} defaultValue={user.name} />
// โ
Or use controlled component (parent owns state)tsxยทState batching and flushSync
// React 18+ batches ALL state updates automatically
// (event handlers, setTimeout, fetch callbacks, promises)
function handleClick() {
setA(1); // โค
setB(2); // โฅ batched โ single re-render
setC(3); // โฆ
}
// Before React 18, only event handler updates were batched.
// Now everything is batched โ one fewer source of bugs.
// Force synchronous render โ use sparingly (e.g. before reading layout)
import { flushSync } from "react-dom";
function handleAdd() {
flushSync(() => {
setItems((prev) => [...prev, newItem]);
});
// DOM is updated NOW โ safe to measure it
listRef.current?.lastElementChild?.scrollIntoView();
}