Web Frameworks · Deep dive
React
Deep dive · 12 min read
React
Status: evaluated (deep dive) Last updated: 2026-06-16 One-line verdict: The industry-default UI library — a runtime + huge ecosystem, maximal hiring/longevity safety, at the cost of more moving parts and a heavier baseline than compiled alternatives.
Snapshot
| What it is | A JavaScript library for building UIs from components. Not a full framework — you assemble routing, data, build, etc. (or adopt a meta-framework like Next.js). |
| Latest stable | React 19.2.x (19.2 shipped 2025-10-01; 19, Dec 2024; 19.1, Jun 2025). |
| Governance | Created at Meta; now under the React Foundation (2025) with platinum members incl. Meta, Amazon, Microsoft, Vercel, Expo. Not a one-vendor project anymore. |
| Model | Runtime + Virtual DOM diffing. Ships a framework runtime to the browser; an opt-in compiler (React Compiler 1.0, late 2025) now automates most memoization. |
| Language | JSX (JS/TS with HTML-like syntax). TypeScript is first-class in practice. |
The mental model
React's core idea: UI is a function of state. You describe what the UI should look like for a given state; React figures out the DOM changes.
view = f(state)
When state changes, your component function re-runs, producing a new description of the UI (a tree of elements). React diffs that against the previous tree (the Virtual DOM) and applies the minimal real-DOM updates. You almost never touch the DOM directly.
The key consequence — and the source of most React friction — is that components re-run top to bottom on every state change. Everything else (hooks, memoization, the dependency-array discipline) exists to manage that re-execution model.
The basics — a working handbook
Goal: after this section you can build a real component — state, effects, derived data, refs, context, forms, lists — without looking elsewhere. Everything here is React 19 with function components (the only style worth learning today; class components are legacy but still supported).
Components, props, and JSX
A component is a function that takes one argument — its props object — and returns JSX. Props are read-only; a component never mutates them.
function Greeting({ name, children }) { // destructure props; `children` = nested JSX
return (
<section>
<h1>Hello, {name}</h1>
{children}
</section>
);
}
// usage
<Greeting name="Homie"><p>Welcome back.</p></Greeting>
JSX essentials: return a single root (or a fragment <>…</>); use
className not class and htmlFor not for; {expr} embeds any JS
expression; component names are Capitalized (lowercase = DOM tag).
The render model and the Rules of Hooks
A component function runs ("renders") to produce JSX, and runs again —
top to bottom — whenever its state or props change. Plain consts are
recomputed every run; hooks are the API for keeping values alive across
runs. Two rules make that bookkeeping work (React tracks hooks by call order):
- Call hooks only at the top level — never inside conditions, loops, or nested functions.
- Call hooks only from components or from other hooks.
Every useX below is a hook and obeys these rules.
useState — local reactive state
Returns a [value, setter] pair. Calling the setter schedules a re-render
with the new value.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0); // 0 is the initial value
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Three things that trip people up:
- State is immutable — replace, never mutate. For objects/arrays, make a
new one:
setUser({ ...user, name }),setList([...list, item]). Mutating in place (user.name = …) will not re-render. - Functional updates when the next value depends on the previous one —
safe against batching and stale closures:
setCount(c => c + 1); // not setCount(count + 1) - Lazy initialization for expensive initial state — pass a function so it
runs only on the first render:
useState(() => expensiveInit()).
Multiple setState calls in the same event are batched into one render.
Derived values — just compute them
Data computed from state or props is not state. Compute it during render;
don't mirror it into useState (that creates two sources of truth to sync).
const fullName = `${first} ${last}`;
const total = items.reduce((s, i) => s + i.price, 0); // recomputed each render
Only reach for useMemo (below) if the computation is genuinely expensive.
useEffect — synchronize with the outside world
Effects run after the render is painted, and are for talking to things outside React: network, subscriptions, timers, manual DOM, logging. The dependency array controls when the effect re-runs:
useEffect(() => { /* runs after every render */ });
useEffect(() => { /* runs once after mount */ }, []);
useEffect(() => { /* runs when `id` changes */ }, [id]);
Return a cleanup function — it runs before the next effect run and on unmount (unsubscribe, abort, clear timers):
function User({ id }) {
const [user, setUser] = useState(null);
useEffect(() => {
let active = true;
fetch(`/api/users/${id}`)
.then(r => r.json())
.then(u => { if (active) setUser(u); });
return () => { active = false; }; // ignore stale response if id changed/unmounted
}, [id]);
return user ? <p>{user.name}</p> : <p>Loading…</p>;
}
Gotchas: the dependency array must list every reactive value the effect
reads (the react-hooks/exhaustive-deps lint enforces this) — miss one and
you get stale data; add an unstable one and you get loops. In development
StrictMode runs effects twice to surface missing cleanup. And in modern
React, raw useEffect for data fetching is discouraged in favor of a
framework loader or a library like TanStack Query.
useRef — a value that survives renders without causing them
A ref is a mutable box (ref.current) that persists across renders but does
not trigger a re-render when changed. Two uses:
// 1) hold a DOM node
const inputRef = useRef(null);
<input ref={inputRef} />;
inputRef.current.focus(); // imperative DOM access
// 2) hold a mutable value between renders (timer id, previous value, etc.)
const renderCount = useRef(0);
renderCount.current += 1; // changing this does NOT re-render
Rule of thumb: if changing it should update the UI, use state; if it's bookkeeping the UI doesn't display, use a ref.
useMemo — cache an expensive calculation
Memoizes a computed value so it's recalculated only when its dependencies change.
const sorted = useMemo(
() => bigList.slice().sort(compare), // skipped unless bigList/compare change
[bigList, compare]
);
Use it for genuinely costly work or to keep a referentially-stable object/array you pass to memoized children. Don't memoize everything — it has its own cost.
useCallback — cache a function's identity
Every render creates new function instances. useCallback returns the
same function instance until its dependencies change. It's useMemo for
functions: useCallback(fn, deps) ≡ useMemo(() => fn, deps).
const handleSelect = useCallback((id) => {
setSelected(id);
}, []); // stable identity across renders
Why identity matters: a new function each render is a new prop, which
defeats React.memo on a child and re-fires effects that list the function as
a dependency. So useCallback is mostly paired with the two below.
React.memo — skip re-rendering a child
Wraps a component so it re-renders only when its props change (shallow
comparison). This is where useCallback/useMemo pay off — they keep the
props referentially stable so memo can actually skip work.
const Row = React.memo(function Row({ item, onSelect }) { … });
// parent: pass stable props so Row can skip re-render
<Row item={item} onSelect={handleSelect /* useCallback'd */} />
The React Compiler (opt-in, 1.0) inserts most
useMemo/useCallback/memoautomatically — when enabled, you write the plain version above and let the build optimize it. Until it's on by default, this trio is manual.
useContext — share data down the tree without prop-drilling
Create a context, wrap a subtree in its provider, and read it anywhere below
with useContext — no passing props through every level.
const ThemeContext = createContext("light");
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
const theme = useContext(ThemeContext); // "dark"
return <div className={theme}>…</div>;
}
Good for app-wide, slowly-changing data (theme, current user, locale). Note:
every consumer re-renders when the provider's value changes, so don't put
rapidly-changing state in one big context.
useReducer — state as explicit transitions
For state with several sub-values or complex update logic, a reducer is
clearer and more testable than many useStates. You dispatch actions; a
pure reducer computes the next state.
function reducer(state, action) {
switch (action.type) {
case "inc": return { count: state.count + 1 };
case "set": return { count: action.value };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: "inc" });
Custom hooks — package reusable stateful logic
A custom hook is just a function named useX that calls other hooks. It lets
you extract and reuse logic (not markup) across components.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(o => !o), []);
return [on, toggle];
}
// usage
const [isOpen, toggleOpen] = useToggle();
Events and forms (controlled inputs)
Events are camelCase props taking a function (onClick, onChange,
onSubmit). A controlled input binds its value to state and updates
state on change — React owns the value:
function NameField() {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={e => setName(e.target.value)}
/>
);
}
Lists and conditionals
Plain JavaScript — ternaries, &&, and map. Every list item needs a
stable key (a unique id, not the array index) so React can track items
across renders:
{loading
? <Spinner />
: items.map(item => <Row key={item.id} item={item} />)}
{error && <p className="error">{error}</p>}
React 19 essentials: use, Actions, optimistic UI
use(promise)— read a promise (or context) directly in render; with<Suspense>it suspends until the value resolves. Simplifies data reading.- Actions — pass an async function to a
<form action={…}>;useActionStatetracks its pending/error/result state for you:const [error, submit, isPending] = useActionState(async (_, formData) => { const res = await save(formData.get("name")); return res.ok ? null : "Save failed"; }, null); <form action={submit}> <input name="name" /> <button disabled={isPending}>Save</button> {error && <p>{error}</p>} </form> useOptimistic— show an optimistic UI immediately while an action is in flight, auto-reverting if it fails.
Concurrency helpers (you'll meet these soon, not on day one)
useTransition— mark a state update as non-urgent so it doesn't block typing/clicks:const [isPending, startTransition] = useTransition().useDeferredValue— render a "lagging" copy of a fast-changing value (e.g. a search box driving an expensive list).useId— generate stable unique ids for accessibility attributes.useLayoutEffect— likeuseEffectbut fires before paint; only for measuring/mutating layout synchronously.
That's the full working set. With useState, derived values, useEffect,
useRef, useMemo/useCallback/memo, useContext, useReducer, custom
hooks, events/forms, and lists, you can build essentially any UI.
Routing and full-stack: the meta-framework question
Background: meta-frameworks.md explains what a meta-framework is and compares the options.
React itself has no router, no data layer, no build setup. You choose:
- Next.js (Vercel) — the dominant full-stack React framework. Owns routing, SSR/SSG, and React Server Components. This is where most new React work happens, but it's an opinionated platform with its own learning curve and a tilt toward Vercel hosting.
- React Router / Remix — routing-first, less prescriptive.
- Vite + React Router — the lightweight "just an SPA" path; closest to a classic client-rendered app, easiest to self-host.
Architecturally important: "adopting React" is really "adopting a React stack." The library is stable; the surrounding choices are where lock-in, churn, and hosting assumptions live.
Rendering models
See web-app-concepts.md for what CSR / SSR / SSG / prerendering mean.
React supports the full spectrum, mostly via the meta-framework:
- CSR — classic client-side SPA.
- SSR / SSG — server-render to HTML, then hydrate.
- React Server Components (RSC) — components that run only on the server,
send zero JS to the client, and stream HTML. Powerful for cutting bundle
size, but they reshape the mental model (server vs client component
boundaries,
"use client") and are tightly coupled to the framework.
Tooling and ecosystem
This is React's decisive advantage.
- Largest ecosystem of any frontend tech: component libraries (MUI, Radix, shadcn/ui), state (Redux Toolkit, Zustand, Jotai), data (TanStack Query), forms, tables, charts — usually multiple mature options each.
- DevTools, profiler, and the deepest pool of Stack Overflow / LLM training data — practical for day-to-day velocity.
- React Native — the same model targets mobile. Relevant if native apps are ever in scope.
- TypeScript support is excellent.
Performance characteristics
- Baseline bundle: ~40–70 KB of framework JS before your app code — the VDOM runtime ships to the client. Less significant as app size grows.
- On the Krausest js-framework-benchmark, React 19 is mid-pack — competent but behind compiled approaches (Svelte) by ~15–30% on create/update/memory in many runs.
- The React Compiler (opt-in build tool, 1.0 late 2025) auto-inserts
memoization, removing much manual
useMemo/useCallbackwork and closing part of the gap. It is not on by default. - Reality check: for most line-of-business apps, framework overhead is rarely the bottleneck — network, DB, and large assets dominate. This matters most for low-end devices, huge lists, or strict load budgets.
Strengths
- Lowest hiring/continuity risk of any option — ~10x more job postings than Svelte; near-infinite learning material and AI assistance.
- Ecosystem depth — a vetted library exists for almost everything.
- Multi-vendor governance reduces single-sponsor risk for long-lived systems.
- Reach — web + React Native + RSC cover most product shapes.
- Backward compatibility — React takes upgrade stability seriously.
Weaknesses / risks
- Conceptual overhead — re-render model, hook rules, dependency arrays, and now server/client component boundaries. Easy to write subtly wrong code.
- Stack assembly — "React" alone is not enough; the real decisions (Next.js? RSC? state lib?) carry the churn and lock-in.
- Heavier baseline than compiled frameworks.
- Ecosystem churn — patterns shift (class→hooks→RSC); long-lived code can strand on old idioms.
How it ages
Strong long-term bet on availability of talent and libraries. The risk is not React the library disappearing — it's stack-level drift (the meta-framework and rendering paradigm you pick today moving under you). For a 10-year system, favor the most conservative slice (e.g. Vite + React Router SPA, or Next.js used plainly) over bleeding-edge RSC patterns.
Fit for our context (.NET, long-lived, safety-adjacent)
| Criterion | Rating | Notes |
|---|---|---|
| Longevity / governance | ++ | Multi-vendor foundation; will outlive most of our systems. |
| .NET / team fit | + | We already have React experience; pairs cleanly with an ASP.NET/Web API backend (SPA + JSON). |
| Maintainability | 0/+ | Great tooling, but re-render/hook discipline and stack drift add long-term cost. |
| Regulatory / safety fit | + | Mature, auditable, huge talent pool for validation/support; self-hostable SPA path avoids vendor coupling. |
| Performance & footprint | 0 | Heaviest baseline of the candidates; fine for desktop-class, watch on constrained devices. |
| Ecosystem & hiring | ++ | Best in class. |
| Rendering / interactivity | ++ | Every model available — but complexity scales with how much you adopt. |
Net: the low-risk default. If the decision criterion is hiring, ecosystem, and longevity, React wins. The cost is paid in conceptual overhead and the discipline needed to keep a long-lived stack from drifting.
Sources
- React v19 (react.dev, 2024-12-05) — Actions,
use, RSC, compiler direction. - React 19.2 (react.dev, 2025-10-01) — latest stable line; Activity,
useEffectEvent, partial pre-rendering. - React reference: Hooks (react.dev) — full hook API used in the handbook above.
- React release history (versionlog) — version/date timeline.
- React (Wikipedia) — governance / React Foundation members.
- Svelte vs React 2026 (Strapi) — bundle/perf and job-market comparison.
- (accessed 2026-06-16; React Compiler stability per react.dev compiler docs — verify exact GA wording before quoting in an ADR.)