article thumbnail
React
The View Layer That Ate the Controller
22 min read
#programming, #react

React is a JavaScript library for building user interfaces, created at Facebook and open-sourced in 2013. It does one thing: render UI and keep it in sync with your data. Everything else — routing, state management, data fetching — you pick yourself. That constraint is both its greatest strength and the source of most "which library do I use?" debates.

The core idea is deceptively simple: describe what your UI should look like for a given state, and React figures out the minimal set of DOM changes to make it happen.


The Component Model

Everything in React is a component — a function that takes data in and returns UI out.

function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

That <h1> syntax isn't HTML — it's JSX, a syntax extension that lets you write markup inside JavaScript. It compiles down to plain function calls (React.createElement), but JSX is what everyone actually uses.

Components compose. You build small, focused pieces and assemble them into larger ones:

function UserCard({ user }) {
  return (
    <div className="card">
      <Avatar src={user.photo} />
      <Greeting name={user.name} />
      <p>{user.bio}</p>
    </div>
  );
}

Each component owns its own rendering logic, styles, and behavior. Change one without touching the others.


Props and State

Props are inputs passed from parent to child — read-only, like function arguments:

<Button label="Save" disabled={false} onClick={handleSave} />

State is data that lives inside a component and triggers a re-render when it changes. You manage it with the useState hook:

function Counter() {
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

The rule: props flow down, events bubble up. A parent passes data to children via props; children communicate back by calling functions the parent passed them.


Hooks

Hooks are functions that let you tap into React features from function components. They all start with use.

Hook Purpose
useState Local component state
useEffect Side effects (fetching, subscriptions, timers)
useContext Read shared context without prop drilling
useRef Mutable ref that doesn't trigger re-renders
useMemo Cache expensive computed values
useCallback Stable function references across renders

useEffect is where most "how does this work?" confusion lives. It runs after render and lets you synchronize with things outside React:

function UserProfile({ userId }) {
  const [user, setUser] = React.useState(null);

  React.useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]); // Re-run when userId changes

  if (!user) return <p>Loading...</p>;
  return <h2>{user.name}</h2>;
}

The dependency array [userId] is the key: list every value the effect reads from the component scope. Empty array [] means run once on mount. No array means run after every render (almost never what you want).


React Is Not MVC

If you've built apps with Rails, Laravel, Django, or any traditional server-side framework, React will feel like it's breaking the rules. In MVC, the View is a dumb template — it displays data, nothing more. Logic lives in the Controller. State lives in the Model. Each layer stays in its lane.

React deliberately collapses that separation. A component contains its markup, its state, and its event handlers all in one place:

function Toggle() {
  const [on, setOn] = React.useState(false);       // model-ish

  const handleClick = () => setOn(!on);             // controller-ish

  return (                                           // view
    <button onClick={handleClick}>
      {on ? 'ON' : 'OFF'}
    </button>
  );
}

Facebook's engineers made this choice deliberately. Their argument: MVC separates by technology, not by concern. Splitting a feature across a model file, a view template, and a controller means touching three files to change one thing. When a Toggle component changes, everything relevant is in one place.

React's philosophy is that a component is the unit of concern. Grouping markup, state, and behavior together isn't a violation of separation of concerns — it is the separation of concerns, just drawn around features instead of layers.

Where MVC lives in a React app:

MVC Layer React Equivalent
Model useState, useReducer, external stores (Zustand, Redux)
View The JSX returned from a component
Controller Event handlers and useEffect inside the component

The layers still exist — they're just colocated rather than split across files. For global state shared across many components, most React apps do pull the Model layer back out into a dedicated store, which starts to look more like classic MVC again.

React also replaces MVC's typically bidirectional data flow (views can update models directly, models can push to views) with unidirectional flow: data only travels down through props, and changes travel back up through callbacks. This is stricter and makes state changes far easier to trace.


JSX Needs a Compiler

Here's something that trips up every first-timer: browsers cannot run JSX. The <h1>Hello</h1> syntax inside your JavaScript is not valid JavaScript. Browsers only understand plain JS. Before your code reaches the browser, something has to translate JSX into function calls the browser can actually execute.

That translation looks like this:

// What you write (JSX):
function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

// What the browser actually runs (plain JS):
function Greeting({ name }) {
  return React.createElement("h1", null, "Hello, ", name, "!");
}

The two are identical in behavior — JSX is purely a developer convenience. Every <Tag /> becomes React.createElement(Tag, props, ...children). You could write React without JSX, but nobody does because it becomes unreadable fast.

This means every React project has a compilation step — a tool that reads your .jsx files and outputs plain .js files that the browser can run. This happens before the browser sees anything.

There are three ways to handle this:

1. Skip it entirely with a CDN (best for learning)
Load React from a CDN and use Babel directly in the browser. Babel is a JavaScript compiler that runs in the browser itself. Slow for production, but zero setup — great for your first experiment.

2. Use esbuild (best for integrating React into an existing server app)
esbuild is a standalone compiler that transforms JSX to plain JS as a one-shot command. Fast, simple, no configuration required. Your server compiles the JSX once and serves the result.

3. Use Vite (best for standalone React apps)
Vite is a full development environment — dev server, hot reload, bundler — built on top of esbuild. The standard choice for greenfield React projects.


Your First React Page (CDN Approach)

Before setting up any tooling, get something running. Save this as a plain .html file and open it in a browser:

<!DOCTYPE html>
<html>
<head>
  <title>First React Page</title>
</head>
<body>
  <div id="root"></div>

  <!-- React and ReactDOM from CDN -->
  <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
  <!-- Babel compiles JSX in the browser -->
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

  <!-- type="text/babel" tells Babel to process this block -->
  <script type="text/babel">
    function Greeting({ name }) {
      return <h1>Hello, {name}!</h1>;
    }

    ReactDOM.createRoot(document.getElementById('root')).render(
      <Greeting name="World" />
    );
  </script>
</body>
</html>

No install, no build step, no terminal. This is how React felt in 2015 and it still works. Once you understand what's happening here, you're ready to graduate to a proper compiler.

The downside: Babel adds ~300KB and compiles JSX in the browser on every page load. Fine for learning, not for production.


esbuild: The Compiler That Gets Out of Your Way

esbuild is a JavaScript compiler and bundler written in Go. Its defining characteristic is speed — it's 10–100x faster than older tools like Babel or Webpack, fast enough that compilation feels instant even for large projects.

For integrating React into an existing server application, esbuild is the right tool. You don't need a dev server or a full build pipeline — just one command that transforms your JSX file into a plain JS file.

Install it once via npm:

npm install -g esbuild

Transform a JSX file:

esbuild mycomponent.jsx --outfile=mycomponent.js

That's it. mycomponent.jsx contains JSX. mycomponent.js is what you serve to the browser. esbuild reads the file, compiles it, writes the output, and exits. No config file. No background process. No watching.

What esbuild actually produces:

// Input: mycomponent.jsx
function Counter() {
  const [count, setCount] = React.useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}
ReactDOM.createRoot(document.getElementById('root')).render(<Counter />);
// Output: mycomponent.js (browser-ready)
function Counter() {
  const [count, setCount] = React.useState(0);
  return React.createElement("div", null,
    React.createElement("p", null, "Count: ", count),
    React.createElement("button", { onClick: () => setCount(count + 1) }, "+")
  );
}
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(Counter, null));

The compiled output assumes React and ReactDOM are already globals — loaded via CDN tags in your HTML before this script runs.

Calling esbuild from a server language:

Because esbuild is just a binary on your PATH, any server language can invoke it:

// PHP
shell_exec('esbuild mycomponent.jsx --outfile=mycomponent.js');
# Python
import subprocess
subprocess.run(['esbuild', 'mycomponent.jsx', '--outfile', 'mycomponent.js'])

This makes esbuild a natural fit for server-driven frameworks that want to add React support without committing to a Node.js-centric build pipeline.


The Virtual DOM

React maintains a lightweight copy of the DOM in memory — the virtual DOM. When state changes:

  1. React re-renders the component tree to a new virtual DOM
  2. It diffs the old and new virtual DOMs (reconciliation)
  3. It applies only the changed nodes to the real DOM

This batching and diffing is what makes React fast for complex UIs with frequent updates. You never manually manipulate document.getElementById — you describe the desired state, React handles the rest.


A Minimal Working App

Here is a complete, self-contained React app — HTML shell included. No build step required, just the CDN approach from above.

<!DOCTYPE html>
<html>
<head>
  <title>Post List</title>
</head>
<body>
  <div id="root"></div>

  <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

  <script type="text/babel">
    function App() {
      const [posts, setPosts] = React.useState([]);
      const [loading, setLoading] = React.useState(true);

      React.useEffect(() => {
        fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
          .then(res => res.json())
          .then(data => {
            setPosts(data);
            setLoading(false);
          });
      }, []);

      if (loading) return <p>Loading...</p>;

      return (
        <ul>
          {posts.map(post => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      );
    }

    ReactDOM.createRoot(document.getElementById('root')).render(<App />);
  </script>
</body>
</html>

Three things to notice: key on list items (React needs it for efficient diffing), state initialized before data arrives, and the empty dependency array [] so the fetch only runs once on mount.

When you're ready to move beyond the CDN, save the <script type="text/babel"> block as app.jsx, run esbuild app.jsx --outfile=app.js, swap the Babel CDN tag for a <script src="app.js">, and you're on a production-ready path.


The Ecosystem

React itself is intentionally minimal. The surrounding ecosystem fills the gaps:

Compilers and Build Tools

State Management

Data Fetching

Styling

For most new standalone projects: Vite + React + TanStack Query + Tailwind covers 90% of what you need.


React vs. The Alternatives

React Vue Svelte
Learning curve Medium Gentle Gentle
Ecosystem size Massive Large Growing
Bundle size Medium Small Tiny
Job market Dominant Solid Niche
Mental model Functional/Hooks Options/Composition Compiled reactivity

React's ecosystem size is hard to overstate. If you're building a production app and need a component library, charting, drag-and-drop, a rich text editor, or a PDF renderer — there's a well-maintained React package for it.


When React Makes Sense

Use React when:

Reconsider when:


Key Concepts at a Glance

Term What It Means
Component A function that returns JSX
JSX HTML-like syntax that compiles to JS — browsers can't run it directly
Compiler A tool (esbuild, Babel, Vite) that transforms JSX into plain JS
Props Read-only inputs from a parent component
State Internal data that triggers re-renders
Hook A function that taps into React features
Virtual DOM React's in-memory DOM copy for diffing
Reconciliation React's process of computing minimal DOM updates
Side effect Anything outside React: fetch, timers, subscriptions

The Bottom Line

React won the UI library wars by being good enough at everything and exceptional at composability. The component model scales from a single widget to a million-line codebase. Hooks replaced class components with something cleaner. The ecosystem handles whatever you need next.

The learning curve is real — hooks take a day to read and weeks to internalize. But once the mental model clicks (describe state, React handles rendering), everything else follows from it.

Start here: copy the CDN example above into an .html file and open it in a browser. No install, no terminal, no configuration. Once you've built something with it, you'll understand exactly what esbuild is replacing and why.


Addendum: React and SEO

One of the most common surprises after learning React is discovering it can be invisible to search engines.

A traditional server-side app renders complete HTML on the server and sends it to the browser. A crawler visits the URL and immediately reads fully-formed content. React, by default, does the opposite — the server sends an empty shell and the browser builds the page using JavaScript:

<body>
  <div id="root"></div>
  <script src="app.js"></script>
</body>

Until that script executes, there is nothing for a crawler to read.

How different crawlers handle it:

The solutions:

Server-Side Rendering (SSR) — React renders to a complete HTML string on the server before the response is sent. Crawlers see full content immediately. The browser then "hydrates" the page — React attaches event listeners to the existing HTML rather than rebuilding it from scratch. This is the most complete solution but requires Node.js on the server. Next.js is the standard framework for this.

Static Site Generation (SSG) — Pages are rendered to HTML at build time and served as static files. Perfect crawler visibility, fastest possible load times, zero server processing per request. The tradeoff is that content is only as fresh as the last build — suitable for blogs, documentation, and marketing pages, less so for frequently changing data. Next.js, Gatsby, and Astro all support this.

Pre-rendering services — A proxy layer (such as Prerender.io) intercepts requests from known crawler user agents, renders the React app in a headless browser, and returns the resulting HTML. Real users still receive the client-side app. This keeps your architecture unchanged at the cost of added infrastructure and a monthly bill.

Pick the right tool for the job — Not every React app needs SEO. Dashboards, admin panels, and anything behind a login have no public crawlers to satisfy — client-side React is the right choice there. For public-facing content where search ranking matters, SSR or SSG is the correct answer from the start. Retrofitting SEO into a client-side app later is significantly more painful than choosing the right rendering strategy upfront.