Blog · Code Jam

What Do You Actually Need React For?

Five different questions, usually asked as one.

Browser requirement, architecture requirement, developer convenience, framework convention, ecosystem hype — build the same feature both ways, inspect the code, and measure what's actually there.

Date TBD

The starting point

Introduction

I have been working with JavaScript since 1998.

I built browser applications before React, before JSX, before npm became the center of frontend engineering, before modern build pipelines, and before component frameworks became the default answer to nearly every frontend architecture question.

I have hand-written complete web experiences. I have written every line of HTML, CSS, and JavaScript. I built proprietary frontend frameworks. I manually handled cross-browser incompatibilities, browser-specific DOM behavior, event-system differences, layout bugs, CSS inconsistencies, and compatibility problems that modern browser standards have largely eliminated.

That history does not make old browser engineering better. A lot of it was terrible. Modern browsers are dramatically better. JavaScript is dramatically better. CSS is dramatically better. Modules are better. Developer tooling is better. Browser APIs are better. Testing is better. Standards are better.

But there is a difference between the platform becoming better and every abstraction layered on top of the platform being necessary. That is the question this article investigates.

The method is experimental, not ideological: build the same experience both ways, inspect the code, measure it, inspect the browser behavior, and compare the resulting architecture. No verdict at the end — a decision model instead.

Note on scope: this site has no React build toolchain, so nothing labeled "React" below actually runs on this page — every React example is real, correct, representative code, shown for comparison only, same as the C/Go/Python examples in the concurrency post.

The framework

The Five-Layer Classification

Most "React vs. vanilla" arguments collapse five genuinely different questions into one. Separating them is most of the work:

{ "elements": [ { "id": "requirement", "label": "Feature Requirement" }, { "id": "browser", "label": "Browser Requirement" }, { "id": "architecture", "label": "Architecture Requirement" }, { "id": "convenience", "label": "Developer Convenience" }, { "id": "convention", "label": "Framework Convention" }, { "id": "hype", "label": "Ecosystem Hype" } ], "connections": [ { "from": "requirement", "to": "browser" }, { "from": "requirement", "to": "architecture" }, { "from": "requirement", "to": "convenience" }, { "from": "requirement", "to": "convention" }, { "from": "requirement", "to": "hype" } ] }

A framework should earn its complexity by solving a problem the application actually has. Framework convenience has real value — but it isn't the same value as a browser requirement or an architecture requirement, and treating it as though it were is exactly how a project ends up with dependencies nobody can name a reason for.

Experiment

Experiment: A Counter

Start with the simplest possible case, because it's the clearest illustration of "developer convenience" as its own category. Both versions below do the exact same three things (increment, decrement, reset). The React version doesn't do anything the native version can't — it's more concise for very small local state, which is real, but it's convenience, not capability.

Experiment

Experiment: Loading Data

An earlier post covered the actual hard part of loading data — not accidentally serializing independent requests. That problem exists identically in both versions below. What changes is the ceremony required to opt into React's render cycle: an effect, a dependency array, and a cleanup function whose entire job is preventing a state update after the component has already unmounted — a failure mode that doesn't exist at all in the native version, because there's no unmount to race against.

Experiment

Experiment: A Reusable Component

Custom Elements are the browser's own answer to "I want a reusable, encapsulated UI component" — no framework, no build step, no virtual DOM. For a component this simple, the two versions are nearly the same size, and the Custom Element has one fewer moving part: no props interface layered over the browser's own attribute system, no separate re-render lifecycle to reason about.

Experiment

Experiment: State Management Across Components

This is the case where the earlier experiments' "it's mostly a wash" conclusion actually flips, and it's worth being precise about why, because the usual story — "prop drilling got so bad that Redux was invented to fix it" — isn't quite what happened. When Redux was introduced in 2015, its pitch was predictable state updates and time-travel debugging, borrowed from Facebook's Flux architecture and the Elm language. Prop drilling is a real, related pain point that a centralized store happens to relieve as a side effect — but the thing actually built specifically to kill prop drilling is React's own Context API, added three years later, in 2018.

What Redux actually is, underneath the branding: one central store, pure reducer functions, and a single dispatch entry point that subscribers read slices of state from. That's a disciplined, narrower cousin of a general publish/ subscribe message bus, not the same thing — a generic bus is many-to-many (anyone can emit, anyone can listen to anything); Redux is deliberately one-way-in. But the family resemblance is real, and it points at something worth stating plainly: none of this requires a framework. JavaScript functions are first-class values — you can store a reference to one in a Map or a Set and call it later, dynamically, from anywhere else in the program. That's the entire mechanism a hand-rolled event bus needs, and it's the same mechanism dispatch/subscribe is built on top of.

The left side threads a theme prop through two components that never use it, purely so it can reach the one that does. The right side skips the threading entirely: any two points in the program can talk directly, and — matching your own instinct here — you can log every emitted event the exact same way Redux's dev tools log every dispatched action, with nothing more than a console.log at the top of emit(). Redux adds real discipline on top of this (pure reducers, a single store, time-travel debugging tooling) that's genuinely valuable once an application's state gets complex enough — but the underlying mechanism making any of it possible is just JavaScript functions being values you can pass around, which was never framework-specific to begin with.

Measured, not eyeballed

The Complexity Ledger

Here's the same three experiments (plus a form-input example), counted rather than just eyeballed: for each one, how many concepts does the actual product requirement need, versus how many exist purely because a framework's own lifecycle requires them? Neither number is a verdict by itself — but making the framework column visible at all is the point, since it's usually invisible once you're used to writing it.

Fair treatment

What React Genuinely Solves

None of this is an argument that React solves nothing. Declarative rendering of complex, deeply nested, frequently-changing UI state is a real problem, and diffing against a virtual representation instead of hand-tracking every DOM mutation is a genuine solution to it — the more state a UI has and the more those pieces of state interact, the more that solution earns its keep. The counter and the card component above are deliberately too simple to show that; a live-updating dashboard with a dozen interacting panels is where the comparison would flip.

Syntax vs. architecture

JSX: What It Actually Buys You

JSX gets folded into "React is unnecessary complexity" arguments more than it earns, in my experience — it's worth separating from the rest of React's architecture, because its actual value proposition is narrower and more defensible than that framing gives it credit for.

The core argument for it, going back to React's earliest public talks, is that "separation of concerns" and "separation of technologies" got conflated somewhere along the way. Keeping HTML in one file, CSS in another, and JavaScript in a third was assumed to mean cleanly separated concerns — but a component's rendering logic and its markup are the same concern; splitting them across three files just moves the coupling somewhere less visible. JSX keeps the two together deliberately, and because it's a real JavaScript expression rather than a string-based template language, everything inside a { } is genuinely just JavaScript — no separate mini-language of directives to learn.

That's also its most honest limitation, and it maps directly onto this article's own five-layer classification: JSX is not a browser requirement. No browser parses it natively — it needs a compile step (Babel, TypeScript, or SWC) before it can run at all, which is exactly the kind of framework-convention cost the counter and reusable-component experiments earlier were measuring. Whether that cost is worth paying depends entirely on whether the colocation benefit is solving a problem your specific project actually has.

Counter-argument

MVVM and the Theming Problem

One honest counter-argument to colocation is worth taking seriously: consistency and reskinning. If markup, logic, and style all live in the same file, swapping a whole site's visual theme, or A/B testing the same widget's presentation across five different pages, means editing components directly instead of swapping one centralized thing. It's worth being precise about scope here, though — that critique lands on CSS-in-JS specifically (styled-components, emotion, and similar libraries the React ecosystem layered on top of JSX), not on JSX's original argument, which was about markup and rendering logic being one concern and said nothing about CSS at all. Plenty of JSX code just applies a className to an entirely separate, centralized stylesheet.

The same tension shows up in plain JavaScript too, not just JSX: the same widget's interaction logic — the same debounce, the same validation, the same event handling — reused across five pages, but needing its styling or its position in a sales funnel A/B tested independently of that logic. The obvious rebuttal is "that's what a component is for" — but a component's reusability only covers whatever variation its author anticipated and exposed as a prop. A visual skin or flow variant nobody planned for means either editing the component directly, or a near-duplicate copy — the exact problem colocation was supposed to solve, showing up again one layer up.

This is a large part of why MVVM (Model-View-ViewModel) keeps resurfacing, though it's worth being precise about the history: MVVM isn't a reaction to JSX. It's a recurring, independently-rediscovered answer to the same underlying problem — keep the view as dumb as possible, no business logic, just bindings, and inject a view-model that owns the actual behavior.

MVVM predates React by about a decade. Microsoft introduced it for WPF around 2005, and Knockout.js brought it to the web in 2010 — three years before JSX existed. It's less "engineers decoupled their way out of JSX's argument" and more that this same idea kept getting rediscovered independently. React's own community arrived at a version of it too, in the "container vs. presentational component" convention that predates hooks.

Building a few sites this way earns the pattern's reputation honestly: a dumb view bound to an injected view-model really does make behavior reusable across wildly different presentations. But it's worth naming the part that doesn't automatically follow from it — decoupling behavior from view doesn't decouple theme from view. Even with a clean MVVM split, the view still owns its own concrete markup and, usually, its own concrete styling. Reskinning it for an A/B test is a third axis, separate from both the model and the view-model, and it needs its own seam: CSS custom properties, design tokens, or a swappable stylesheet the view reads from, rather than values baked into the view itself.

None of this is an argument against colocation, against JSX, or against MVVM — it's the same lesson this article keeps landing on from different directions: reusability, theming, and behavior are three different problems, and the pattern that solves one of them for free doesn't automatically solve the other two. Vue, next, is itself often described as MVVM-influenced — worth watching for as its reactivity model comes into view.

A different model

A Different Model: Vue.js

Vue is worth its own section because it makes a genuinely different set of tradeoffs than React, not just a different-flavored version of the same ones. Where React re-renders a component function and diffs the result against a virtual DOM, Vue 3's reactivity system tracks dependencies automatically through JavaScript Proxies: mutate a reactive value directly and Vue already knows which parts of the template depend on it, without an explicit setter call triggering a full component re-render.

Vue's template compiler takes this further, at build time — it can statically determine which parts of a template can never change and skip diffing them entirely (Vue 3's "PatchFlags"), which is a different, and in some cases more precise, optimization strategy than runtime virtual-DOM diffing across the whole tree.

The syntax choice matters here too. Vue's Single-File Components use an HTML-like template with directives (v-if, v-for, v-model) rather than JSX's plain JavaScript expressions — closer to what someone coming from vanilla HTML already knows, at the cost of a small template-specific vocabulary to learn. And unlike JSX, Vue can run with no build step at all, loaded straight from a script tag — which puts it in an interesting middle position on this article's own classification: capable of being a pure browser-and-CDN dependency, or a full build-tooled Single-File-Component workflow, depending on what the project actually needs.

Adjacent tooling

Next.js: Solving Problems React Itself Doesn't

This is worth being precise about, because it's the single most common thing people give React itself credit for that it doesn't actually do. React alone renders on the client. A plain React single-page app ships an empty <div id="root"> and builds the real content in the browser after the JavaScript loads and runs — which means a crawler, or a social-media link-preview scraper, that doesn't execute JavaScript sees nothing there at all.

Next.js — a meta-framework built on top of React, not a part of React itself — is what actually solves that. Server-side rendering and static generation produce real HTML before it ever reaches the browser, so there's genuine content for a crawler to read regardless of whether it executes JavaScript. File-based routing removes the need for a separate routing library and its configuration. And with the App Router's React Server Components, a component can query a database directly and never ship its JavaScript to the client at all — which means prototyping a data-backed page no longer requires hand-building a separate API endpoint first.

None of this is a React feature. It's exactly the "architecture requirement vs. framework convention" distinction from earlier in this article, just answered by a different, adjacent tool instead of by React itself — worth remembering the next time "React handles SEO fine" gets stated as though it were true of React alone.

A caveat

A Note on Security Assumptions

A related post covers this in depth, but it's worth restating here specifically: a component is not automatically a security boundary, a state architecture, or a domain boundary. JSX defaulting to text-escaping is a genuine safety improvement over raw string concatenation into innerHTML — but React is not a browser security boundary. It's an implementation running inside the browser, subject to the exact same origin rules and parsing behavior as anything else, including whatever "render raw HTML" escape hatch it still exposes for the cases text-escaping can't cover.

In practice

A Real Workflow: Vanilla Seed, FastAPI, Agent-Driven Migration

Here's where all of this actually lands in practice, in a specific shape I keep reaching for: a FastAPI backend built around DDD and bounded contexts, with explicit API contracts, paired with exactly the kind of vanilla HTML/CSS/JS reference frontend this whole article has been arguing has real merit — minimal dependencies, explicit browser/API edges, nothing hidden behind a framework's own opinions. That reference frontend isn't the deployed product. It's the seed: the thing that proves the API contract actually works end to end, cheaply, before committing to a specific framework for the CDN-facing production frontend — and increasingly, the thing a coding agent migrates from, into Next.js, Nuxt, or Angular, once the contract and the behavior are proven.

{ "elements": [ { "id": "contract", "label": "Domain / API Contract" }, { "id": "fastapi", "label": "FastAPI Application" }, { "id": "reference", "label": "Vanilla Reference Frontend" }, { "id": "tests", "label": "Tests / Behavior" }, { "id": "migration", "label": "Coding-Agent Migration" }, { "id": "nextjs", "label": "Next.js (React)" }, { "id": "nuxt", "label": "Nuxt (Vue)" }, { "id": "angular", "label": "Angular" }, { "id": "regression", "label": "Regression Validation" }, { "id": "production", "label": "CDN / Production" } ], "connections": [ { "from": "contract", "to": "fastapi" }, { "from": "fastapi", "to": "reference" }, { "from": "reference", "to": "tests" }, { "from": "tests", "to": "migration" }, { "from": "migration", "to": "nextjs" }, { "from": "migration", "to": "nuxt" }, { "from": "migration", "to": "angular" }, { "from": "nextjs", "to": "regression" }, { "from": "nuxt", "to": "regression" }, { "from": "angular", "to": "regression" }, { "from": "regression", "to": "production" } ] }

The interesting question isn't really "which framework is best" — it's which framework an agent can migrate into deterministically, given the same vanilla seed and the same behavioral tests to satisfy. That's a different axis than the ones earlier in this article, and it doesn't sort the same way:

Fit for vanilla to framework migration Comparison of Next.js, Nuxt and Angular for migration from vanilla JavaScript, security defaults, architectural enforcement, minimal package potential and agent migration determinism. 0 2.5 5 7.5 10 Next.js Nuxt Angular Migration from vanilla Security defaults Architectural enforcement Minimal-package potential Agent migration determinism 10 = particularly strong fit
Fit for vanilla → framework migration, scored 0–10 as comparative judgments, not benchmark measurements.

Angular scores lowest here on rapid prototyping and highest on architectural enforcement and security defaults — the same tradeoff opinionated frameworks always make, just more consequential when the thing doing the migrating is an autonomous agent rather than a human exercising judgment. Angular's TypeScript-first design, built-in dependency injection, and strict compiler narrow the space of "valid" ways to migrate a piece of vanilla behavior down to nearly one — which is exactly what agent determinism rewards. Next.js's flexibility, which is a genuine strength for a human team making its own architectural choices, becomes a liability for an agent: there are more equally-valid ways to structure the migration, which means less consistency across repeated runs. Nuxt lands in an unusually strong middle position across nearly every row, which tracks with the Vue reactivity model covered earlier in this article — a template-based, convention-heavy structure gives an agent nearly as narrow a decision space as Angular's, without Angular's steeper prototyping cost.

See the full 13-criterion comparison table
Criterion Vanilla seed Next.js Nuxt Angular
Rapid prototype ★★★★★ ★★★ ★★★★ ★★
Explicit browser behavior ★★★★★ ★★★ ★★★★ ★★★
Easy for agent to understand ★★★★★ ★★★ ★★★★ ★★★★
Migration from vanilla ★★★★ ★★★★½ ★★★
Framework security defaults ★★ ★★★★ ★★★★ ★★★★★
Architectural enforcement ★★★ ★★★★ ★★★★★
DDD compatibility ★★★★★ ★★★★ ★★★★ ★★★★★
Minimal-package potential ★★★★★ ★★★ ★★★★ ★★★★
Static/CDN deployment ★★★★★ ★★★★★ ★★★★★ ★★★★★
Full-stack capability ★★★★★ ★★★★★ ★★★★
Framework complexity Very low High Medium High
Agent migration determinism Medium High High
Long-term structural consistency Depends on you Medium High Very high

Note the one row that doesn't discriminate at all: static/CDN deployment. All four score essentially the same, because all four support pre-rendering or static export in some form — it's not actually a differentiator for this decision, despite being the kind of thing that shows up on every "why we chose X" blog post. The rows that actually separate these options are the ones about how much of the decision space the framework itself removes, which is precisely the axis a human-led evaluation tends to under-weight and an agent-led migration can't afford to.

Putting it together

The Decision Model

Put the five-layer classification and the experiments together, and the actual decision looks like this:

{ "elements": [ { "id": "start", "label": "Product Requirement" }, { "id": "browser-check", "label": "Browser Already Solves This?", "shape": "decision" }, { "id": "architecture-check", "label": "Real Architecture Requirement?", "shape": "decision" }, { "id": "earns-complexity", "label": "Does React Earn Its Complexity?", "shape": "decision" }, { "id": "use-react", "label": "Use React" }, { "id": "stay-native", "label": "Stay Native" } ], "connections": [ { "from": "start", "to": "browser-check" }, { "from": "browser-check", "to": "stay-native", "label": "yes" }, { "from": "browser-check", "to": "architecture-check", "label": "no" }, { "from": "architecture-check", "to": "earns-complexity", "label": "either way" }, { "from": "earns-complexity", "to": "use-react", "label": "yes" }, { "from": "earns-complexity", "to": "stay-native", "label": "no" } ] }

Where this lands

Conclusion

Use React when React solves a problem you actually have. Use browser-native primitives when the browser already solves the problem cleanly. Use Custom Elements when you need stable, reusable browser components without a build step. Treat developer convenience as real value — but don't confuse it with a browser requirement or an application architecture requirement. Direct DOM manipulation is not automatically an architecture. Neither is React. Architecture comes from ownership, boundaries, data flow, state flow, and failure behavior — the same four questions the concurrency post ended on, asked here about a completely different layer of the stack.

One more distinction worth closing on, since it's easy to blur: everything above is about which UI framework, if any, a feature actually needs. That's a separate question from which build tool serves it — and it's worth naming that Vite, the tool serving every interactive demo on this page, picked its battles well. It has no opinion about React vs. Vue vs. vanilla; it isn't trying to be a UI framework at all. It focused on the actual foundational pain point underneath all of them: a dev server that starts instantly regardless of project size, because it serves native ES modules directly to the browser instead of bundling the whole app up front, with hot module replacement that stays fast as a project grows, and a production build that still tree-shakes and code-splits properly when it matters. That's the same discipline this whole article has been arguing for, applied to tooling instead of the UI layer: solve the problem that's actually there, and stay out of the way of the decisions that aren't yours to make.

There's a team-collaboration angle to all of this worth naming directly, because it's the actual shape my own dev cycles take now. A working vanilla-JS prototype, built against a real backend contract, isn't throwaway scaffolding — it's a source of truth I can hand to a UI/UX team and say "this is the real behavior; restyle it, rearrange it, make it yours." That handoff used to carry a real cost: whatever the design side produced — a static mockup, a prototype in a different tool entirely — had to be reimplemented from scratch by engineering anyway, so the prototype's only real job was communication, never code. Coding agents change that math. Migrating a proven vanilla prototype into Next.js or Nuxt is now cheap enough that the prototype is the accelerator, not a throwaway step before the real work starts — the UI/UX team gets a real, running artifact to iterate against instead of a description of one, and the eventual production migration starts from validated behavior instead of a spec that has to be reinterpreted from scratch.

The vanilla-seed-and-agent-migration workflow above deserves a deeper treatment than fits here — particularly the harder question this article only gestures at: what actually makes a codebase legible enough for an agent to migrate deterministically in the first place. That's the subject of 2026 Web Development: Dissecting the AI SDLC.