1. React Rendering Lifecycle Overview

Every component goes through the same basic cycle: Trigger → Render phase → Commit phase. On state changes, this cycle repeats (Update); when removed from the tree, cleanup happens (Unmount).

Trigger
Initial mount or state/prop change
Render phase
Function body runs (pure, no side effects!), produces a new element/fiber tree
Commit phase
React writes changes to the DOM, sets refs
useLayoutEffect
runs synchronously, still BEFORE the browser paint (blocking)
Browser paints the screen
useEffect
runs asynchronously AFTER the paint (non-blocking)
Update cycle
State/prop change → cleanup function of the previous effect (if dependencies changed) → render phase again → commit phase → new effect
Unmount
Component is removed from the tree → cleanup functions of all active effects run one last time

Virtual DOM & reconciliation

In the render phase, React doesn't produce a real DOM but a lightweight tree of plain JavaScript objects – the so-called virtual DOM (more precisely: an element/fiber tree). A completely new such tree is created on every render, no matter how small the actual change was. React then compares this new tree with the last committed tree (this diffing process is called reconciliation) and from it computes the minimal set of real DOM operations needed to bring the screen up to date. Only these computed changes are actually applied in the commit phase – the entire tree is never rebuilt from scratch on the page.

The reason for this detour: comparing two lightweight JS object trees is very cheap, while direct DOM operations (reflow, repaint) are expensive. By first comparing "in its head" (virtually) and only then writing the actual differences to the real DOM in a targeted way, React keeps updates performant even for large UIs.

How does React know what has changed? When comparing two child lists, React relies on the key prop (see section 8): same key at the same position → React assumes it's "the same" element and only updates the changed attributes; different/missing key (or a change of element type, e.g. divspan) → React removes the old DOM node along with its internal state (focus, scroll position, inputs) and creates a new one.

Why must the render phase be pure (free of side effects)?

Because the render phase "only" computes a virtual tree and doesn't commit anything yet, React can pause, discard, or run it multiple times (concurrent rendering) at any point without anything becoming visible on screen. For this reason, a component's function body must not trigger any visible side effects during this phase (no DOM manipulation, no network calls, no mutating external state) – otherwise effects could, in the worst case, run twice or not at all. Side effects belong exclusively in useEffect/useLayoutEffect (which only run after the commit) or in event handlers.

Timing.jsx – order of state, effects, and layout effects
import { useState, useEffect, useLayoutEffect, useRef } from "react";

function Timing() {
  const [count, setCount] = useState(0);
  const renderCountRef = useRef(0);

  renderCountRef.current++;
  console.log("1) Render phase – function body runs, count =", count);

  useLayoutEffect(() => {
    console.log("2) useLayoutEffect – runs synchronously BEFORE the paint");
    return () => console.log("   Cleanup of useLayoutEffect (before next layout effect / on unmount)");
  }, [count]);

  useEffect(() => {
    console.log("3) useEffect – runs AFTER the paint (asynchronous)");
    return () => console.log("   Cleanup of useEffect (before next effect / on unmount)");
  }, [count]);

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Clicks: {count}
    </button>
  );
}
export default Timing;
useEffect vs. useLayoutEffect – rule of thumb Use useEffect for anything that doesn't need to be immediately visible (data fetching, logging, subscriptions, timers). Use useLayoutEffect only when you need to read/measure the DOM (e.g. getBoundingClientRect) and then want to adjust something synchronously before the browser paints – otherwise you get visible flicker.
Strict Mode & concurrent rendering – double rendering in dev In <React.StrictMode> (development build only), React 18/19 deliberately runs the render function and effects twice for every component – mount, unmount, mount again (including a cleanup call in between). This surfaces unclean effects (missing cleanup, side effects in render). This doesn't happen in the production build. For the same reason, the render phase can be interrupted and discarded by concurrent features (useTransition, Suspense) – another reason it must be strictly free of side effects.

2. The Most Important Hooks at a Glance

Hooks are the central mechanism for incorporating state, side effects, and reusable logic into function components. They are always called at the top level of the component (not inside loops/conditions).

React's core hooks – purpose and typical use case
HookPurposeTypical use case
useStateLocal, primitive state within a componentForm field, counter, toggle state
useEffectRun a side effect after rendering/commit (asynchronous, after paint)Data fetching, subscriptions, timers, logging
useLayoutEffectRun a side effect synchronously before the browser paintMeasuring the DOM and correcting layout, tooltip positioning, scroll restoration
useContextRead a value from a context without prop drillingTheme, logged-in user, language/i18n
useMemoCache the result of an expensive calculation until dependencies changeFiltering/sorting large lists, derived values
useCallbackKeep a function reference stable until dependencies changePassing a callback to a memoized child component (React.memo)
useRefA mutable value that does NOT trigger a re-render; also used for DOM node referencesSetting focus, remembering a timer ID, storing a previous value
useReducerControlling more complex state transitions via actions/a reducer functionMulti-step forms, state machines, many related fields
useTransitionMarking a state update as "low priority" so the UI stays responsive in the meantimeFiltering large lists/search results without blocking keyboard input
Custom hook (useXyz)Encapsulating reusable, stateful logic built from several base hooksuseDebounce, useFetch, useLocalStorage (see section 13)
Note on React 19 Among other things, React 19 adds useActionState, useOptimistic, and the generic use() call (e.g. to conditionally read promises/context), as well as actions for forms. This reference deliberately focuses on the established core hooks; React 19-specific additions are briefly mentioned at the end.

useState – local state

Returns a value and a setter function; calling the setter triggers a re-render. The function updater (c => c + 1) is safer than count + 1, because it always builds on the most current value, even if several updates happen in quick succession.

useState
function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Value: {count}
    </button>
  );
}

useEffect – side effect after the commit

Runs after the browser paint, whenever one of the specified dependencies has changed since the last render (see also section 19).

useEffect
function DocumentTitle({ unreadCount }) {
  useEffect(() => {
    document.title = unreadCount > 0 ? `(${unreadCount}) Inbox` : "Inbox";
  }, [unreadCount]);

  return <p>Unread: {unreadCount}</p>;
}

useLayoutEffect – synchronous before the paint

Like useEffect, but blocking and before the browser paint – needed when a DOM measurement must immediately feed into a layout adjustment to avoid flicker.

useLayoutEffect
function Tooltip({ text }) {
  const ref = useRef(null);
  const [offsetTop, setOffsetTop] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setOffsetTop(-(height + 8)); // correct the position BEFORE the paint
  }, [text]);

  return (
    <div ref={ref} style={{ position: "absolute", top: offsetTop }}>
      {text}
    </div>
  );
}

useContext – reading a value from a context

Reads the current value of the nearest Provider in the tree, without it having to be passed through as a prop (see also section 4).

useContext
const ThemeContext = createContext("light");

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return <button className={`btn btn-${theme}`}>Click me</button>;
}

useMemo – caching an expensive calculation

Only recalculates the value when one of the dependencies has changed; otherwise it returns the cached result from the last render.

useMemo
function ProductList({ products, query }) {
  const filtered = useMemo(
    () => products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
    [products, query]
  );

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

useCallback – stable function reference

Prevents a new function instance from being created on every render – important when the function is passed as a prop to a child component memoized with React.memo, and unnecessary re-renders should be avoided.

useCallback
const SubmitButton = React.memo(function SubmitButton({ onClick }) {
  console.log("SubmitButton renders");
  return <button onClick={onClick}>Submit</button>;
});

function Form() {
  const [text, setText] = useState("");

  // Without useCallback, a new function would be created on every
  // keystroke, causing SubmitButton to re-render despite React.memo.
  const handleSubmit = useCallback(() => {
    console.log("sent");
  }, []);

  return (
    <>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <SubmitButton onClick={handleSubmit} />
    </>
  );
}

useRef – a mutable value without a re-render

The return value (.current) can be changed freely without triggering a re-render. Two main use cases: accessing a real DOM node, or caching a value across render cycles (e.g. a timer ID).

useRef
function AutoFocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus(); // direct DOM access, no re-render needed
  }, []);

  return <input ref={inputRef} placeholder="Focus lands here automatically" />;
}

useReducer – complex state transitions

Bundles state changes into a pure reducer function that computes the new state from the previous state and an action – clearer than many individual useState calls once several fields are related.

useReducer
function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return { count: 0 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </>
  );
}

useTransition – low priority for state updates

Marks a state update as interruptible/not urgent, so that urgent updates (e.g. keyboard input) continue to be processed immediately in the meantime. isPending indicates whether the low-priority update is still running.

useTransition
function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  const [filtered, setFiltered] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleChange(event) {
    const value = event.target.value;
    setQuery(value); // urgent: show immediately in the input

    startTransition(() => {
      // low priority: allowed to be delayed/interrupted
      setFiltered(items.filter((i) => i.includes(value)));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Updating list…</span>}
      <ul>
        {filtered.map((i) => (
          <li key={i}>{i}</li>
        ))}
      </ul>
    </>
  );
}

Custom hooks – encapsulating your own logic

Any function whose name starts with use and that internally calls other hooks is a custom hook. This lets you share stateful logic between components without HOCs or render props.

Custom hook: useToggle
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  const toggle = useCallback(() => setValue((v) => !v), []);
  return [value, toggle];
}

// Usage:
function Accordion() {
  const [isOpen, toggleOpen] = useToggle(false);
  return (
    <>
      <button onClick={toggleOpen}>{isOpen ? "Close" : "Open"}</button>
      {isOpen && <p>Content…</p>}
    </>
  );
}

You'll find a more complete example (useDebounce, including cleanup) in section 13.

3. Component Composition Patterns

React offers several patterns for flexibly composing components instead of modeling behavior through deep inheritance hierarchies.

PatternIdeaWhen it makes sense
Children propsArbitrary JSX content is "passed through" via props.childrenLayout wrappers, cards, modal shells
Compound componentsSeveral components implicitly share state via context, but are used as a related "family" (<Tabs><Tabs.Tab/></Tabs>)Tabs, accordion, select/menu with flexible markup
Render propsA prop is a function that returns JSX; the component calls it with internal stateReusable logic that leaves the presentation to the caller (today often replaced by custom hooks)
Higher-order components (HOC)A function that takes a component and returns a new, "enriched" componentCross-cutting concerns such as withAuth(Component), withLogging(Component) (today mostly replaced by hooks)

Example: compound components (Tabs)

Tabs.jsx
import { createContext, useContext, useState } from "react";

const TabsContext = createContext(null);

function Tabs({ defaultValue, children }) {
  const [active, setActive] = useState(defaultValue);
  return (
    <TabsContext.Provider value={{ active, setActive }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }) {
  return <div className="tab-list" role="tablist">{children}</div>;
}

function Tab({ value, children }) {
  const { active, setActive } = useContext(TabsContext);
  return (
    <button
      role="tab"
      aria-selected={active === value}
      onClick={() => setActive(value)}
    >
      {children}
    </button>
  );
}

function TabPanel({ value, children }) {
  const { active } = useContext(TabsContext);
  if (active !== value) return null;
  return <div role="tabpanel">{children}</div>;
}

// "Composed" API: Tabs.List, Tabs.Tab, Tabs.Panel
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

export default Tabs;

// Usage:
// <Tabs defaultValue="general">
//   <Tabs.List>
//     <Tabs.Tab value="general">General</Tabs.Tab>
//     <Tabs.Tab value="security">Security</Tabs.Tab>
//   </Tabs.List>
//   <Tabs.Panel value="general">General content</Tabs.Panel>
//   <Tabs.Panel value="security">Security content</Tabs.Panel>
// </Tabs>

The big advantage: the caller freely determines the order and markup structure, while Tabs internally manages the state (which tab is active) and distributes it to all children via context – without having to manually pass props through.

4. State Management Levels

React has no built-in "scopes" like server-side frameworks – instead, you deliberately choose the "reach" in which a piece of state lives.

LevelAPILifetime / visibilityTypical use
Local stateuseState / useReducerLives only within a component instance, disappears on unmountForm field, toggle, counter, UI state
ContextcreateContext / useContextLives as long as the provider is mounted in the tree; visible to all descendants of the providerTheme, logged-in user, language – data needed "across" many components
External storeZustand, Redux Toolkit, Jotai, etc.Lives outside the component tree (module singleton); survives the mount/unmount of individual components, typically for the entire app sessionShopping cart, global app state, complex/frequently updated data read by many unrelated components

Local state

Counter.jsx
import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Value: {count}
    </button>
  );
}
export default Counter;

Context API

ThemeContext.jsx
import { createContext, useContext, useState } from "react";

const ThemeContext = createContext("light");

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

// Usage in any descendant component:
function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      Current theme: {theme}
    </button>
  );
}

Global state with Zustand

cartStore.js
import { create } from "zustand";

export const useCartStore = create((set) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  clear: () => set({ items: [] }),
}));

// Usage in ANY component, with no provider wrapper at all:
function CartBadge() {
  const itemCount = useCartStore((state) => state.items.length);
  return <span className="badge">{itemCount}</span>;
}

5. Comparing State Approaches

Prop drilling, context, and an external store all solve the same problem ("how does a value get to a distant component") with different trade-offs.

ApproachAdvantagesDisadvantagesWhen it makes sense
Prop drilling Explicit, easy to follow, no extra dependency Becomes unwieldy in deep tree structures; intermediate components must pass through props they don't need themselves Shallow component trees, few levels (2–3)
Context API No more manual pass-through needed; built into React Every change to the context value re-renders all consumers, regardless of which part of the value they actually use; with frequently changing values (e.g. form inputs), cascading re-renders across the entire subtree can occur Rarely changing, "global" values (theme, auth user, language), moderate update frequency
External store (Zustand/Redux) Selective re-rendering only for components that read the specifically changed slice (via a selector); state lives independently of the component tree; DevTools/middleware available Extra dependency, more boilerplate/learning curve, potential "magic" with complex selectors Frequently updated, app-wide state with many unrelated consumers (shopping cart, notifications, complex form data across multiple routes)
Downsides of too much context A single large "app context" with many fields causes even the smallest change (e.g. a keystroke in a search field) to re-render all consumers – even ones that use entirely different values from the context. Remedy: split the context into several, domain-separated providers, stabilize values with useMemo, or use an external store with selector support directly for frequently changing data.

6. Navigation/Routing with React Router

React itself contains no routing – react-router-dom (currently v6/v7) is the de facto standard for client-side routing in SPAs.

App.jsx – route definitions, nested routes
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from "react-router-dom";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="users" element={<Users />}>
            <Route path=":userId" element={<UserDetail />} />
          </Route>
          <Route path="contact" element={<ContactForm />} />
          <Route path="*" element={<NotFound />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/users">Users</Link>
        <Link to="/contact">Contact</Link>
      </nav>
      {/* Outlet renders whichever child route currently matches (nested routes) */}
      <Outlet />
    </div>
  );
}

function ContactForm() {
  const navigate = useNavigate();

  function handleSubmit(event) {
    event.preventDefault();
    // ... submit the form ...
    navigate("/"); // redirect after a successful submit
  }

  return (
    <form onSubmit={handleSubmit}>
      <button type="submit">Submit</button>
    </form>
  );
}

export default App;

With useParams(), UserDetail reads :userId from the URL (see also section 15). For declarative redirects without an event (e.g. protecting a route), the <Navigate to="/login" /> element is a good fit.

7. Error Handling

Error Boundaries

Error boundaries catch rendering errors in their child tree and show a fallback instead of the whole app going blank. They currently have to be implemented as a class component, because React needs the lifecycle methods static getDerivedStateFromError and componentDidCatch for this – no hook equivalent exists (as of React 19), since hooks cannot catch errors from rendering child components. In practice, most people therefore use the ready-made react-error-boundary library, which encapsulates this class and offers a functional API.

ErrorBoundary.jsx – the only class component you need
import { Component } from "react";

class ErrorBoundary extends Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    console.error("Unexpected error:", error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? <p>Something went wrong.</p>;
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

// Usage:
// <ErrorBoundary fallback={<p>Unfortunately, this view has crashed.</p>}>
//   <Dashboard />
// </ErrorBoundary>
Alternative: react-error-boundary npm i react-error-boundary gives you <ErrorBoundary FallbackComponent={...} onReset={...}> as a ready-made component – you never have to write the class yourself, you just consume it functionally.

try/catch in async event handlers

Error boundaries do not catch errors from event handlers or asynchronous code. Plain old try/catch is responsible for that.

SaveButton.jsx
function SaveButton({ data }) {
  async function handleClick() {
    try {
      const response = await fetch("/api/save", {
        method: "POST",
        body: JSON.stringify(data),
        headers: { "Content-Type": "application/json" },
      });
      if (!response.ok) throw new Error("Save failed: " + response.status);
      alert("Saved!");
    } catch (error) {
      console.error(error);
      alert("Error while saving: " + error.message);
    }
  }

  return <button onClick={handleClick}>Save</button>;
}

Error handling during data fetching

With plain fetch in useEffect, the error state has to be tracked manually in its own piece of state. Libraries such as React Query (@tanstack/react-query) take care of this, including retry logic.

UserProfile.jsx – with React Query
import { useQuery } from "@tanstack/react-query";

function UserProfile({ userId }) {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: () =>
      fetch(`/api/users/${userId}`).then((res) => {
        if (!res.ok) throw new Error("User not found");
        return res.json();
      }),
  });

  if (isLoading) return <p>Loading…</p>;
  if (isError) return <p>Error: {error.message}</p>;
  return <p>{data.name}</p>;
}

8. JSX & Expressions

JSX is a syntax extension of JavaScript that allows HTML-like markup directly in the code. A build tool (Babel/SWC) translates JSX into plain React.createElement(...) calls – in the end, only JavaScript runs in the browser.

ConstructExampleMeaning
Expression {}<p>{user.name}</p>Any JS expression is evaluated and inserted
Conditional (&&){isAdmin && <AdminPanel />}Only render the element if the condition is true
Conditional (ternary){isLoading ? <Spinner /> : <Content />}Render one of two elements
List rendering{items.map((i) => <li key={i.id}>{i.label}</li>)}Turning an array into elements; key must be stable and unique (not the array index if the order can change)
Fragment<><Header /><Body /></>Returning multiple elements without creating an extra DOM element
JsxBasics.jsx
function UserCard({ user, isAdmin, permissions }) {
  return (
    <>
      <h3>{user.name}</h3>

      {isAdmin && <span className="badge">Admin</span>}

      {user.active ? (
        <span className="status ok">Active</span>
      ) : (
        <span className="status inactive">Inactive</span>
      )}

      <ul>
        {permissions.map((p) => (
          <li key={p.id}>{p.label}</li>
        ))}
      </ul>
    </>
  );
}
Common mistake with && {count && <Badge />} renders a visible "0" instead of nothing when count === 0, because 0 is a falsy but still renderable value. Better: use {count > 0 && <Badge />} or a ternary.

9. Data Fetching (the "Ajax" equivalent)

The classic approach is fetch/axios inside a useEffect, including cleanly aborting in-flight requests on unmount or when dependencies change.

UserList.jsx – fetch with AbortController
import { useEffect, useState } from "react";

function UserList({ searchTerm }) {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function loadUsers() {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch(`/api/users?q=${searchTerm}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error("Server error: " + res.status);
        const data = await res.json();
        setUsers(data);
      } catch (err) {
        if (err.name !== "AbortError") setError(err);
      } finally {
        setLoading(false);
      }
    }

    loadUsers();

    // Cleanup: abort the in-flight request if searchTerm changes
    // or the component unmounts
    return () => controller.abort();
  }, [searchTerm]);

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}
Alternative: React Query / SWR For anything beyond a single simple fetch, @tanstack/react-query or swr pay off: they handle caching, automatic refetching on window focus, deduplication of parallel requests, retry logic, and loading/error states – you just write useQuery({ queryKey, queryFn }) instead of manually maintaining useEffect plus three useState calls.

10. An End-to-End Example

Layout component, a route with a form, input, submit handler, and display of the result state – the complete flow from route to display.

FeedbackPage.jsx
import { useState } from "react";
import { Outlet, Link } from "react-router-dom";

// --- Layout ---
function AppLayout() {
  return (
    <div className="app">
      <header>
        <Link to="/feedback">Feedback</Link>
      </header>
      <main>
        <Outlet />
      </main>
    </div>
  );
}

// --- Route with form ---
function FeedbackPage() {
  const [message, setMessage] = useState("");
  const [submittedList, setSubmittedList] = useState([]);
  const [status, setStatus] = useState("idle"); // idle | sending | done | error

  async function handleSubmit(event) {
    event.preventDefault();
    if (!message.trim()) return;

    setStatus("sending");
    try {
      const res = await fetch("/api/feedback", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message }),
      });
      if (!res.ok) throw new Error("Send failed");

      // Update state: add the new message to the list
      setSubmittedList((prev) => [...prev, message]);
      setMessage("");
      setStatus("done");
    } catch {
      setStatus("error");
    }
  }

  return (
    <section>
      <h1>Feedback</h1>

      <form onSubmit={handleSubmit}>
        <label htmlFor="message">Your message</label>
        <textarea
          id="message"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
        />
        <button type="submit" disabled={status === "sending"}>
          {status === "sending" ? "Sending…" : "Submit"}
        </button>
      </form>

      {status === "done" && <p>Thank you for your feedback!</p>}
      {status === "error" && <p>An error occurred.</p>}

      <h2>Previous messages in this session</h2>
      <ul>
        {submittedList.map((msg, i) => (
          <li key={i}>{msg}</li>
        ))}
      </ul>
    </section>
  );
}

export { AppLayout, FeedbackPage };

// Wiring into the routes (see section 6):
// <Route path="/" element={<AppLayout />}>
//   <Route path="feedback" element={<FeedbackPage />} />
// </Route>

11. Showing/Hiding UI Elements

There are two fundamentally different ways to make an element visible/invisible: purely visually via CSS on the existing DOM node (without a re-render), or via conditional rendering with state (with a re-render, the DOM node is removed/recreated).

Variant A: toggling a CSS class via ref (no state, no re-render)
import { useRef } from "react";

function CollapsibleNoState({ children }) {
  const panelRef = useRef(null);

  function toggle() {
    // Direct DOM manipulation – React knows nothing about this, there is no re-render
    panelRef.current.classList.toggle("hidden");
  }

  return (
    <div>
      <button onClick={toggle}>Show/hide</button>
      <div ref={panelRef} className="panel">
        {children}
      </div>
    </div>
  );
}
Variant B: conditional rendering via useState (with re-render)
import { useState } from "react";

function CollapsibleWithState({ children }) {
  const [open, setOpen] = useState(true);

  return (
    <div>
      <button onClick={() => setOpen((o) => !o)}>Show/hide</button>
      {open && <div className="panel">{children}</div>}
    </div>
  );
}
CriterionCSS class via ref (without state)Conditional rendering via state
Re-render triggered?No – the React tree stays unchangedYes – the component (and possibly children) re-renders
DOM node / internal state of the child treeIs preserved (e.g. scroll position, input values, video playback position)Is lost as soon as the node is removed from the tree (on showing again: reinitialization, effects run again)
Performance with very frequent togglingVery cheap (pure CSS operation)Somewhat more expensive (React reconciliation), usually negligible in practice
Accessibility of hidden contentContent still exists in the DOM (aria-hidden may be needed)Content doesn't exist in the DOM when hidden (cleaner for screen readers)
When it makes sensePurely visual states, high-frequency toggling, animations, when internal state should be preservedWhen the element should really be gone (also from an accessibility/SEO perspective), or when visibility is tied to business/domain state

12. Form Validation with react-hook-form + Zod

react-hook-form manages form state efficiently (uncontrolled inputs, minimal re-renders), zod defines declarative validation rules that are wired in via @hookform/resolvers/zod.

RegisterForm.jsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

// Validation rules, analogous to Bean Validation annotations (@NotNull, @Email, @Size ...)
const schema = z.object({
  username: z.string().min(3, "At least 3 characters").max(20, "At most 20 characters"),
  email: z.string().email("Invalid email address"),
  age: z
    .number({ invalid_type_error: "Please enter a number" })
    .int()
    .min(18, "Minimum age is 18"),
});

function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { username: "", email: "", age: 18 },
  });

  async function onValid(data) {
    await fetch("/api/register", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
  }

  return (
    <form onSubmit={handleSubmit(onValid)}>
      <label>
        Username
        <input {...register("username")} />
      </label>
      {errors.username && <p className="error">{errors.username.message}</p>}

      <label>
        Email
        <input type="email" {...register("email")} />
      </label>
      {errors.email && <p className="error">{errors.email.message}</p>}

      <label>
        Age
        <input type="number" {...register("age", { valueAsNumber: true })} />
      </label>
      {errors.age && <p className="error">{errors.age.message}</p>}

      <button type="submit" disabled={isSubmitting}>Register</button>
    </form>
  );
}

export default RegisterForm;

The advantage of this combination: the Zod schema is the "single source of truth" for validation rules and – unlike pure client-side validation – can be reused identically on the server side as well (e.g. in a Node/Express or Next.js route).

13. A Custom Hook as an Example of Reusability

A custom hook is simply a function whose name starts with use and that internally calls other hooks. This lets you share stateful logic between components without HOCs or render props.

useDebounce.js
import { useEffect, useState } from "react";

/**
 * Delays adopting a value by `delay` milliseconds.
 * Useful e.g. for live search fields, to avoid firing a
 * request on every single keystroke.
 */
function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cleanup: discard the old timer if value/delay
    // change again before the time has elapsed
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;
SearchBox.jsx – usage
import { useState, useEffect } from "react";
import useDebounce from "./useDebounce";

function SearchBox() {
  const [input, setInput] = useState("");
  const debouncedInput = useDebounce(input, 400);

  useEffect(() => {
    if (!debouncedInput) return;
    fetch(`/api/search?q=${debouncedInput}`);
    // ... process the result ...
  }, [debouncedInput]);

  return (
    <input
      value={input}
      onChange={(e) => setInput(e.target.value)}
      placeholder="Search…"
    />
  );
}

14. File Upload

Files are selected via <input type="file">, read from event.target.files in the change handler, and typically uploaded via FormData + fetch.

FileUpload.jsx
import { useState } from "react";

function FileUpload() {
  const [file, setFile] = useState(null);
  const [status, setStatus] = useState("idle");

  function handleFileChange(event) {
    const selected = event.target.files?.[0] ?? null;
    setFile(selected);
  }

  async function handleUpload() {
    if (!file) return;

    const formData = new FormData();
    formData.append("file", file);

    setStatus("uploading");
    try {
      const res = await fetch("/api/upload", {
        method: "POST",
        body: formData, // Do NOT set Content-Type manually – the browser
                         // automatically adds the correct multipart boundary header
      });
      if (!res.ok) throw new Error("Upload failed");
      setStatus("done");
    } catch (err) {
      console.error(err);
      setStatus("error");
    }
  }

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFileChange} />
      {file && <p>Selected: {file.name} ({Math.round(file.size / 1024)} KB)</p>}
      <button onClick={handleUpload} disabled={!file || status === "uploading"}>
        Upload
      </button>
      {status === "done" && <p>Upload successful!</p>}
      {status === "error" && <p>Error during upload.</p>}
    </div>
  );
}

export default FileUpload;

15. URL Query Parameters & Bookmarkability

For a view to be shareable/bookmarkable via URL, its state (e.g. an ID or a filter) belongs in the URL – not just in internal state. React Router provides useParams (path segments) and useSearchParams (query string) for this.

ProductDetail.jsx – ID in the path, filter in the query string
import { useParams, useSearchParams, Link } from "react-router-dom";

// Route: <Route path="/products/:productId" element={<ProductDetail />} />
// Example URL called: /products/42?tab=reviews

function ProductDetail() {
  const { productId } = useParams();
  const [searchParams, setSearchParams] = useSearchParams();
  const activeTab = searchParams.get("tab") ?? "info";

  function selectTab(tab) {
    setSearchParams({ tab }); // updates the URL without reloading the page
  }

  return (
    <div>
      <h1>Product #{productId}</h1>

      <nav>
        <button onClick={() => selectTab("info")}>Info</button>
        <button onClick={() => selectTab("reviews")}>Reviews</button>
      </nav>

      {activeTab === "info" && <p>Product information for {productId}…</p>}
      {activeTab === "reviews" && <p>Reviews for {productId}…</p>}

      <Link to={`/products/${productId}?tab=reviews`}>
        Direct link to the reviews
      </Link>
    </div>
  );
}

export default ProductDetail;

Because productId and tab are part of the URL, browser back/forward, bookmarks, and shared links all work correctly – revisiting the URL restores exactly the same state.

16. Asset & Resource Handling

Styling: CSS Modules vs. styled-components

CSS Modules (Button.module.css) automatically generate unique class names at build time, so styles don't accidentally collide – but the syntax stays plain CSS. styled-components (or alternatives such as Emotion) define styles directly as JavaScript template literals tied to a component, including props-based dynamic styling.

CSS Modules
// Button.module.css
// .primary { background: royalblue; color: white; }

import styles from "./Button.module.css";

function Button({ children }) {
  return <button className={styles.primary}>{children}</button>;
}
styled-components
import styled from "styled-components";

const PrimaryButton = styled.button`
  background: ${(props) => (props.$danger ? "crimson" : "royalblue")};
  color: white;
  padding: 8px 16px;
`;

function Button({ children, danger }) {
  return <PrimaryButton $danger={danger}>{children}</PrimaryButton>;
}

Image imports

Logo.jsx
import logoUrl from "./assets/logo.png"; // Build tool provides a (possibly hashed) URL

function Header() {
  return <img src={logoUrl} alt="Company logo" width={120} />;
}

Code splitting with React.lazy + Suspense

Large, rarely needed components (e.g. an admin area) can be loaded via dynamic import only when they're actually needed – this shrinks the initial JS bundle.

App.jsx
import { lazy, Suspense } from "react";

const AdminPanel = lazy(() => import("./AdminPanel.jsx"));

function App() {
  return (
    <Suspense fallback={<p>Loading admin area…</p>}>
      <AdminPanel />
    </Suspense>
  );
}

17. Internationalization with react-i18next

react-i18next loads translations from JSON files per language and, via the useTranslation hook, provides a t() function as well as switching of the active language.

locales/de/translation.json
{
  "greeting": "Hallo, {{name}}!",
  "nav": { "home": "Start", "contact": "Kontakt" }
}
locales/en/translation.json
{
  "greeting": "Hello, {{name}}!",
  "nav": { "home": "Home", "contact": "Contact" }
}
i18n.js – basic setup
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import de from "./locales/de/translation.json";
import en from "./locales/en/translation.json";

i18n.use(initReactI18next).init({
  resources: { de: { translation: de }, en: { translation: en } },
  lng: "de",
  fallbackLng: "en",
  interpolation: { escapeValue: false },
});

export default i18n;
Greeting.jsx – usage including language switching
import { useTranslation } from "react-i18next";

function Greeting({ userName }) {
  const { t, i18n } = useTranslation();

  return (
    <div>
      <p>{t("greeting", { name: userName })}</p>
      <nav>
        <a href="/">{t("nav.home")}</a>
        <a href="/contact">{t("nav.contact")}</a>
      </nav>

      <button onClick={() => i18n.changeLanguage("de")}>DE</button>
      <button onClick={() => i18n.changeLanguage("en")}>EN</button>
    </div>
  );
}

18. State Persistence & Security Aspects

Mirroring state in localStorage/sessionStorage

A simple, hand-written sync effect is enough for many cases; for more complex global stores, redux-persist (or Zustand's built-in persist middleware) handles this automatically, including rehydration on startup.

usePersistentState.js – custom useEffect sync
import { useState, useEffect } from "react";

function usePersistentState(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage: const [draft, setDraft] = usePersistentState("feedback-draft", "");
Zustand with the persist middleware
import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useSettingsStore = create(
  persist(
    (set) => ({
      theme: "light",
      setTheme: (theme) => set({ theme }),
    }),
    { name: "app-settings" } // localStorage key
  )
);

CSRF handling for API calls

With session-cookie-based authentication, a CSRF token must be sent along with state-changing requests (POST/PUT/DELETE), usually as a header whose value was previously read from a cookie or a server-rendered meta tag.

apiClient.js
function getCsrfToken() {
  return document
    .querySelector('meta[name="csrf-token"]')
    ?.getAttribute("content");
}

async function apiPost(url, body) {
  return fetch(url, {
    method: "POST",
    credentials: "include", // send the session cookie along
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": getCsrfToken(),
    },
    body: JSON.stringify(body),
  });
}

export { apiPost };
Note Sensitive data (tokens, personal data) should not be unnecessarily mirrored into localStorage, since it's readable by any JavaScript running on the same origin (including via XSS). For auth tokens, server-issued httpOnly cookies are the safer alternative.

19. Multi-Step Forms/Wizards & useEffect as a Lifecycle Hook

Wizard with useReducer

RegistrationWizard.jsx
import { useReducer } from "react";

const initialState = { step: 1, data: { name: "", email: "", plan: "" } };

function reducer(state, action) {
  switch (action.type) {
    case "NEXT_STEP":
      return { ...state, step: state.step + 1 };
    case "PREV_STEP":
      return { ...state, step: state.step - 1 };
    case "UPDATE_FIELD":
      return { ...state, data: { ...state.data, [action.field]: action.value } };
    default:
      return state;
  }
}

function RegistrationWizard() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      {state.step === 1 && (
        <input
          placeholder="Name"
          value={state.data.name}
          onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "name", value: e.target.value })}
        />
      )}
      {state.step === 2 && (
        <input
          placeholder="Email"
          value={state.data.email}
          onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "email", value: e.target.value })}
        />
      )}
      {state.step === 3 && <p>Summary: {state.data.name}, {state.data.email}</p>}

      {state.step > 1 && <button onClick={() => dispatch({ type: "PREV_STEP" })}>Back</button>}
      {state.step < 3 && <button onClick={() => dispatch({ type: "NEXT_STEP" })}>Next</button>}
    </div>
  );
}

useEffect as a lifecycle hook: dependency array variants

Dependency arrayWhen does the effect run?Typical use
No array: useEffect(fn)After every render (mount and every update)Rarely useful, usually a sign of a misunderstanding
Empty array: useEffect(fn, [])Only once, right after mountOne-time initialization, setting up a subscription
With dependencies: useEffect(fn, [a, b])After mount, and thereafter whenever a or b have changed since the last renderData fetching dependent on an ID, reacting to a prop/state change
Cleanup function (return value)Runs before the next effect run (when dependencies changed) as well as one last time on unmountClearing timers, removing event listeners, aborting requests (see AbortController in section 9)

Effects are thus functionally the counterpart to event-based reacting to lifecycle transitions: instead of listening for named phase changes, you declare what an effect depends on – React then decides for itself when it needs to (re-)run.

ChatRoom.jsx – effect with cleanup on a changing dependency
import { useEffect, useState } from "react";

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    console.log("Connecting to room", roomId);
    const connection = createConnection(roomId); // fictional API
    connection.on("message", (msg) => setMessages((m) => [...m, msg]));
    connection.connect();

    return () => {
      console.log("Disconnecting from room", roomId);
      connection.disconnect();
    };
  }, [roomId]); // On roomId change: first disconnect the old connection, then establish the new one

  return (
    <ul>
      {messages.map((m, i) => (
        <li key={i}>{m}</li>
      ))}
    </ul>
  );
}

Conclusion & Open Points

The 19 sections above cover everyday React work: the rendering model, hooks, composition, state levels, routing, error handling, forms, data fetching, and a few cross-cutting topics (i18n, persistence, security).

A few topics I deliberately did not go into depth on, which could still be relevant for a complete overview going forward:

TopicWhy relevant
Server Components (RSC) & frameworks like Next.jsFundamentally changes where components run (server vs. client) and how data fetching/bundle size work – a substantial topic of its own
Suspense for data fetching (not just code splitting)React 19/frameworks allow use() with promises directly in rendering instead of manual loading state
Testing (React Testing Library, Vitest/Jest)Without automated tests, any reference remains just "theory" – component, hook, and integration tests would be a sensible next building block
Performance fine-tuning (React.memo, Profiler, virtualized lists)Important once lists/trees grow larger
React 19 actions/useActionState/useOptimisticA newer, increasingly recommended approach specifically for form submits with server interaction

Feel free to let me know if any of these points (or another one) should be added as its own section.

Note on brand and product names: The product, company and brand names mentioned in this document — including React, React Router, Vite, Create React App (CRA), Zustand, Redux Toolkit, Jotai, react-error-boundary, npm, React Query, Babel, SWC, SWR, react-hook-form, Zod, Node.js, Express, Next.js, styled-components, Emotion, react-i18next, i18next, redux-persist, React Testing Library, Vitest, Jest — are trademarks or registered trademarks of their respective rights holders. They are used here solely for illustrative and explanatory purposes; no affiliation, endorsement, or partnership with the respective companies is implied.