← Dashboard

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):

  1. Call hooks only at the top level — never inside conditions, loops, or nested functions.
  2. 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/ memo automatically — 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={…}>; useActionState tracks 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 — like useEffect but 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/useCallback work 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