Intermediate Requires: beginner foundations

React.js
Intermediate Guide

Go beyond the basics — master hooks in depth, performance patterns, data fetching, and scalable architecture.

useEffect in depth

useEffect is for synchronising your component with something outside React — timers, subscriptions, APIs, DOM manipulation. Understanding its dependency array is critical.

The three forms

The behaviour of useEffect is controlled entirely by its second argument.

SignatureRuns whenCommon use case
useEffect(fn)After every renderRarely — usually a mistake
useEffect(fn, [])Once on mountFetch initial data, set up subscriptions
useEffect(fn, [a, b])When a or b changesReact 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>;
}

Custom hooks

Custom hooks let you extract and share stateful logic between components — without changing your component tree. They're just functions that start with use.

The pattern

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>;
}
More useful custom hooks to build

These are common patterns worth knowing:

useFetch useDebounce useMediaQuery useToggle usePrevious useEventListener useOnClickOutside
// 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.

Context API

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.

The three pieces

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.

useReducer

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.

useStateuseReducer
Best forSimple, independent valuesComplex state with multiple transitions
UpdatesDirect value setDispatch named actions
TestingTest via componentReducer is a pure function — easy to unit test
DebuggingHarder to trace transitionsActions 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.

Performance

React is fast by default. Optimise only when you have a measured problem. The three main tools are React.memo, useMemo, and useCallback.

React.memo

Skips re-rendering a component if its props haven't changed. Wrap components that are expensive to render and receive the same props frequently.

useMemo

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.

Code splitting with lazy()

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>
  );
}

Data fetching

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.

ApproachGood forHandles caching?
fetch + useEffectSimple one-off requestsNo
Custom useFetch hookReusable basic fetchingNo
TanStack QueryProduction appsYes — automatic
SWRLightweight cachingYes — 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>;
}
TanStack Query (recommended)

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>;
}

Component patterns

These are recurring structural patterns that solve common problems — separation of concerns, flexibility, reusability — in React component design.

Compound components

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>
Render props

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>} />

React Router

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>;
}
Protected routes

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>} />

Forms

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.

ControlledReact Hook Form
Value stored inReact stateDOM (via refs)
Re-renders on typeYesNo — very performant
ValidationManualBuilt-in + Zod integration
Best forSimple formsComplex/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.

Testing basics

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.

The testing philosophy

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();
  });
});
1

render()

Mounts the component into a virtual DOM for testing.

2

screen queries

Use getByText, getByRole, getByLabelText to find elements the way a user would.

3

fireEvent / userEvent

Simulate user interactions. Prefer @testing-library/user-event over fireEvent for more realistic behaviour.

4

expect()

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.