Go beyond the basics — master hooks in depth, performance patterns, data fetching, and scalable architecture.
01 — Side effects
useEffect is for synchronising your component with something outside React — timers, subscriptions, APIs, DOM manipulation. Understanding its dependency array is critical.
The behaviour of useEffect is controlled entirely by its second argument.
| Signature | Runs when | Common use case |
|---|---|---|
| useEffect(fn) | After every render | Rarely — usually a mistake |
| useEffect(fn, []) | Once on mount | Fetch initial data, set up subscriptions |
| useEffect(fn, [a, b]) | When a or b changes | React to specific state/prop changes |
import { useState, useEffect } from "react"; function Timer() { const [seconds, setSeconds] = useState(0); useEffect(() => { // Setup: starts a timer on mount const id = setInterval(() => { setSeconds(s => s + 1); // functional update avoids stale closure }, 1000); // Cleanup: runs before unmount or before re-running return () => clearInterval(id); }, []); // empty array = run once on mount return <p>Elapsed: {seconds}s</p>; }
Common mistake: putting a function inside the dependency array creates an infinite loop if the function is recreated on every render. Wrap it in useCallback first, or move it inside the effect.
Always return a cleanup function when your effect creates a subscription, timer, or event listener — otherwise you'll get memory leaks and bugs when components unmount.
// Reacting to a specific value changing function SearchResults({ query }) { const [results, setResults] = useState([]); useEffect(() => { if (!query) return; let cancelled = false; // handle race conditions fetch(`/api/search?q=${query}`) .then(r => r.json()) .then(data => { if (!cancelled) setResults(data); }); return () => { cancelled = true; }; }, [query]); // re-runs whenever query changes return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>; }
02 — Reusability
Custom hooks let you extract and share stateful logic between components — without changing your component tree. They're just functions that start with use.
If you find yourself copying state + effect logic across multiple components, extract it into a custom hook. The hook manages its own state internally and returns only what the component needs.
// Custom hook: useLocalStorage import { useState } from "react"; function useLocalStorage(key, initialValue) { const [value, setValue] = useState(() => { try { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : initialValue; } catch { return initialValue; } }); const set = (newValue) => { setValue(newValue); localStorage.setItem(key, JSON.stringify(newValue)); }; return [value, set]; } // Usage — reads/writes to localStorage automatically function Settings() { const [theme, setTheme] = useLocalStorage("theme", "dark"); return <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}> Current: {theme} </button>; }
These are common patterns worth knowing:
// useFetch — a minimal data-fetching hook function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { setLoading(true); fetch(url) .then(r => r.json()) .then(setData) .catch(setError) .finally(() => setLoading(false)); }, [url]); return { data, loading, error }; }
Custom hooks compose too — useFetch can be built on top of useLocalStorage to add caching. Think of hooks like functions: small, single-purpose, composable.
03 — Global state
Context lets you share values across the component tree without passing props at every level. It's ideal for themes, auth state, language preferences, and other app-wide data.
createContext — creates the context object.
Provider — wraps the part of the tree that needs access.
useContext — reads the value inside any child.
// 1. Create the context + a custom hook for it import { createContext, useContext, useState } from "react"; const ThemeContext = createContext(null); export function ThemeProvider({ children }) { const [theme, setTheme] = useState("dark"); return ( <ThemeContext.Provider value={{ theme, setTheme }}> {children} </ThemeContext.Provider> ); } export function useTheme() { return useContext(ThemeContext); } // 2. Wrap your app function App() { return ( <ThemeProvider> <Navbar /> <Main /> </ThemeProvider> ); } // 3. Consume anywhere in the tree function Navbar() { const { theme, setTheme } = useTheme(); return <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}> Toggle theme </button>; }
Performance note: every component that calls useContext re-renders when the context value changes. For high-frequency updates (like mouse position), prefer local state or a library like Zustand instead.
04 — Complex state
When state logic gets complex — multiple sub-values, transitions that depend on each other — useReducer gives you a structured, predictable alternative to a tangle of useState calls.
| useState | useReducer | |
|---|---|---|
| Best for | Simple, independent values | Complex state with multiple transitions |
| Updates | Direct value set | Dispatch named actions |
| Testing | Test via component | Reducer is a pure function — easy to unit test |
| Debugging | Harder to trace transitions | Actions are explicit and loggable |
import { useReducer } from "react"; // The reducer: pure function (state, action) => newState function cartReducer(state, action) { switch (action.type) { case "ADD_ITEM": return { ...state, items: [...state.items, action.item], total: state.total + action.item.price }; case "REMOVE_ITEM": return { ...state, items: state.items.filter(i => i.id !== action.id) }; case "CLEAR": return { items: [], total: 0 }; default: return state; } } function Cart() { const [cart, dispatch] = useReducer(cartReducer, { items: [], total: 0 }); return ( <div> <p>{cart.items.length} items — £{cart.total}</p> <button onClick={() => dispatch({ type: "CLEAR" })}>Clear cart</button> </div> ); }
Combine useReducer with useContext for a lightweight global store pattern — pass dispatch through context and any component can trigger state transitions without prop drilling.
05 — Optimisation
React is fast by default. Optimise only when you have a measured problem. The three main tools are React.memo, useMemo, and useCallback.
Skips re-rendering a component if its props haven't changed. Wrap components that are expensive to render and receive the same props frequently.
Caches the result of an expensive calculation between renders. Only recalculates when its dependencies change.
import { memo, useMemo, useCallback, useState } from "react"; // React.memo — skip re-render if props are the same const ExpensiveList = memo(function ExpensiveList({ items }) { return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>; }); function App() { const [filter, setFilter] = useState(""); const rawItems = useLargeDataset(); // useMemo — only refilter when rawItems or filter changes const filtered = useMemo( () => rawItems.filter(i => i.name.includes(filter)), [rawItems, filter] ); // useCallback — stable reference for the handler const handleSelect = useCallback((id) => { console.log("selected", id); }, []); // no deps — never recreated return <ExpensiveList items={filtered} onSelect={handleSelect} />; }
Don't over-optimise. useMemo and useCallback themselves have a cost. Profile first with React DevTools Profiler, then apply memoisation only where renders are measurably slow.
Split large components into separate bundles that only load when needed. React's lazy and Suspense make this straightforward.
import { lazy, Suspense } from "react"; // Load HeavyDashboard only when it's actually rendered const HeavyDashboard = lazy(() => import("./HeavyDashboard")); function App() { return ( <Suspense fallback=<p>Loading...</p>> <HeavyDashboard /> </Suspense> ); }
06 — Async data
Fetching data is one of the most common tasks in React apps. There are several approaches — from rolling your own hook, to using a dedicated library like TanStack Query.
| Approach | Good for | Handles caching? |
|---|---|---|
| fetch + useEffect | Simple one-off requests | No |
| Custom useFetch hook | Reusable basic fetching | No |
| TanStack Query | Production apps | Yes — automatic |
| SWR | Lightweight caching | Yes — stale-while-revalidate |
// Manual approach with loading + error state function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { setLoading(true); setError(null); fetch(`/api/users/${userId}`) .then(res => { if (!res.ok) throw new Error("Not found"); return res.json(); }) .then(setUser) .catch(setError) .finally(() => setLoading(false)); }, [userId]); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error.message}</p>; return <h2>{user.name}</h2>; }
For real apps, TanStack Query handles caching, background refetching, pagination, and loading/error states — removing most of the boilerplate above.
import { useQuery } from "@tanstack/react-query"; function UserProfile({ userId }) { const { data: user, isLoading, error } = useQuery({ queryKey: ["user", userId], queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()), }); if (isLoading) return <p>Loading...</p>; if (error) return <p>Error</p>; return <h2>{user.name}</h2>; }
07 — Architecture
These are recurring structural patterns that solve common problems — separation of concerns, flexibility, reusability — in React component design.
Break a complex component into sub-components that share state via context. The parent owns the state; the children render parts of it. Great for things like tabs, dropdowns, and accordions.
// Tabs built as compound components const TabsContext = createContext(null); function Tabs({ children, defaultTab }) { const [active, setActive] = useState(defaultTab); return ( <TabsContext.Provider value={{ active, setActive }}> {children} </TabsContext.Provider> ); } Tabs.Tab = function Tab({ id, children }) { const { active, setActive } = useContext(TabsContext); return <button onClick={() => setActive(id)} className={active === id ? "active" : ""}> {children} </button>; }; Tabs.Panel = function Panel({ id, children }) { const { active } = useContext(TabsContext); return active === id ? <div>{children}</div> : null; }; // Usage <Tabs defaultTab="a"> <Tabs.Tab id="a">Overview</Tabs.Tab> <Tabs.Tab id="b">Details</Tabs.Tab> <Tabs.Panel id="a">Overview content</Tabs.Panel> <Tabs.Panel id="b">Details content</Tabs.Panel> </Tabs>
Pass a function as a prop that returns JSX. The component calls the function with its internal data, giving the consumer full control over rendering. Less common now that hooks exist, but still useful.
// A mouse-position tracker via render prop function MouseTracker({ render }) { const [pos, setPos] = useState({ x: 0, y: 0 }); return ( <div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}> {render(pos)} </div> ); } // Usage <MouseTracker render={pos => <p>x: {pos.x}, y: {pos.y}</p>} />
08 — Navigation
React Router v6 is the standard way to add multi-page navigation to React apps. It handles URL matching, nested routes, redirects, and protected routes.
import { BrowserRouter, Routes, Route, Link, useParams, Navigate } from "react-router-dom"; function App() { return ( <BrowserRouter> <nav> <Link to="/">Home</Link> <Link to="/about">About</Link> </nav> <Routes> <Route path="/" element=<Home /> /> <Route path="/about" element=<About /> /> <Route path="/user/:id" element=<User /> /> <Route path="*" element=<NotFound /> /> </Routes> </BrowserRouter> ); } // Reading URL params function User() { const { id } = useParams(); return <h1>User {id}</h1>; }
Redirect unauthenticated users by wrapping routes in a component that checks auth state and redirects to login if needed.
function ProtectedRoute({ children }) { const { user } = useAuth(); // your auth hook if (!user) return <Navigate to="/login" replace />; return children; } // Usage <Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
09 — User input
Forms in React fall into two categories: controlled (React drives the values) and uncontrolled (the DOM does). For complex forms with validation, React Hook Form is the go-to library.
| Controlled | React Hook Form | |
|---|---|---|
| Value stored in | React state | DOM (via refs) |
| Re-renders on type | Yes | No — very performant |
| Validation | Manual | Built-in + Zod integration |
| Best for | Simple forms | Complex/production forms |
// React Hook Form example import { useForm } from "react-hook-form"; function SignupForm() { const { register, handleSubmit, formState: { errors } } = useForm(); const onSubmit = (data) => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register("email", { required: "Email is required", pattern: { value: /^\S+@\S+$/i, message: "Invalid email" } })} placeholder="Email" /> {errors.email && <p>{errors.email.message}</p>} <input {...register("password", { required: true, minLength: 8 })} type="password" placeholder="Password" /> <button type="submit">Sign up</button> </form> ); }
Pair React Hook Form with Zod (@hookform/resolvers/zod) to define a schema once and get both TypeScript types and runtime validation for free.
10 — Quality
React Testing Library is the standard for testing React components. It encourages testing from the user's perspective — interacting with what's visible on screen rather than implementation details.
Test what the user sees and does, not how your code works internally. Query elements by accessible text, labels, and roles — not by class names or internal state. If a refactor doesn't break the user experience, the test shouldn't break either.
import { render, screen, fireEvent } from "@testing-library/react"; import { Counter } from "./Counter"; describe("Counter", () => { it("starts at zero", () => { render(<Counter />); expect(screen.getByText("Count: 0")).toBeInTheDocument(); }); it("increments when button is clicked", () => { render(<Counter />); fireEvent.click(screen.getByRole("button", { name: /add one/i })); expect(screen.getByText("Count: 1")).toBeInTheDocument(); }); });
Mounts the component into a virtual DOM for testing.
Use getByText, getByRole, getByLabelText to find elements the way a user would.
Simulate user interactions. Prefer @testing-library/user-event over fireEvent for more realistic behaviour.
Assert what should be visible or true after an interaction.
For async tests (data fetching, loading states), use await screen.findByText() instead of getByText() — it waits for the element to appear in the DOM.