React – Concepts Overview
A systematic reference to the core concepts of React (functional components & hooks, React 18/19). All code examples are deliberately kept compact, but runnable and directly portable into a Vite/CRA project.
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).
Initial mount or state/prop change
Function body runs (pure, no side effects!), produces a new element/fiber tree
React writes changes to the DOM, sets refs
runs synchronously, still BEFORE the browser paint (blocking)
runs asynchronously AFTER the paint (non-blocking)
State/prop change → cleanup function of the previous effect (if dependencies changed) → render phase again → commit phase → new effect
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.
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. div → span) → 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.
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 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.
<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).
| Hook | Purpose | Typical use case |
|---|---|---|
useState | Local, primitive state within a component | Form field, counter, toggle state |
useEffect | Run a side effect after rendering/commit (asynchronous, after paint) | Data fetching, subscriptions, timers, logging |
useLayoutEffect | Run a side effect synchronously before the browser paint | Measuring the DOM and correcting layout, tooltip positioning, scroll restoration |
useContext | Read a value from a context without prop drilling | Theme, logged-in user, language/i18n |
useMemo | Cache the result of an expensive calculation until dependencies change | Filtering/sorting large lists, derived values |
useCallback | Keep a function reference stable until dependencies change | Passing a callback to a memoized child component (React.memo) |
useRef | A mutable value that does NOT trigger a re-render; also used for DOM node references | Setting focus, remembering a timer ID, storing a previous value |
useReducer | Controlling more complex state transitions via actions/a reducer function | Multi-step forms, state machines, many related fields |
useTransition | Marking a state update as "low priority" so the UI stays responsive in the meantime | Filtering large lists/search results without blocking keyboard input |
Custom hook (useXyz) | Encapsulating reusable, stateful logic built from several base hooks | useDebounce, useFetch, useLocalStorage (see section 13) |
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.
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).
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.
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).
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.
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.
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).
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.
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.
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.
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.
| Pattern | Idea | When it makes sense |
|---|---|---|
| Children props | Arbitrary JSX content is "passed through" via props.children | Layout wrappers, cards, modal shells |
| Compound components | Several 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 props | A prop is a function that returns JSX; the component calls it with internal state | Reusable 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" component | Cross-cutting concerns such as withAuth(Component), withLogging(Component) (today mostly replaced by hooks) |
Example: compound components (Tabs)
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.
| Level | API | Lifetime / visibility | Typical use |
|---|---|---|---|
| Local state | useState / useReducer | Lives only within a component instance, disappears on unmount | Form field, toggle, counter, UI state |
| Context | createContext / useContext | Lives as long as the provider is mounted in the tree; visible to all descendants of the provider | Theme, logged-in user, language – data needed "across" many components |
| External store | Zustand, Redux Toolkit, Jotai, etc. | Lives outside the component tree (module singleton); survives the mount/unmount of individual components, typically for the entire app session | Shopping cart, global app state, complex/frequently updated data read by many unrelated components |
Local state
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
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
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.
| Approach | Advantages | Disadvantages | When 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) |
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.
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.
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>
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.
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.
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.
| Construct | Example | Meaning |
|---|---|---|
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 |
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>
</>
);
}
&&
{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.
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>
);
}
@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.
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).
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>
);
}
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>
);
}
| Criterion | CSS class via ref (without state) | Conditional rendering via state |
|---|---|---|
| Re-render triggered? | No – the React tree stays unchanged | Yes – the component (and possibly children) re-renders |
| DOM node / internal state of the child tree | Is 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 toggling | Very cheap (pure CSS operation) | Somewhat more expensive (React reconciliation), usually negligible in practice |
| Accessibility of hidden content | Content 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 sense | Purely visual states, high-frequency toggling, animations, when internal state should be preserved | When 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.
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.
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;
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.
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.
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.
// Button.module.css
// .primary { background: royalblue; color: white; }
import styles from "./Button.module.css";
function Button({ children }) {
return <button className={styles.primary}>{children}</button>;
}
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
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.
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.
{
"greeting": "Hallo, {{name}}!",
"nav": { "home": "Start", "contact": "Kontakt" }
}
{
"greeting": "Hello, {{name}}!",
"nav": { "home": "Home", "contact": "Contact" }
}
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;
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.
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", "");
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.
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 };
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
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 array | When 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 mount | One-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 render | Data 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 unmount | Clearing 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.
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:
| Topic | Why relevant |
|---|---|
| Server Components (RSC) & frameworks like Next.js | Fundamentally 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/useOptimistic | A 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.