Development/Engineering

The Story of a React Developer Who Wanted to Leave React

A longtime React developer’s notes on how React became the web’s default, what that cost in complexity, and what WordPress and PHP taught about choosing tools to fit the problem.

By Sogo Kim 2026.09.28 20 min read
ai · frontend · php · react · web

This site is built with WordPress and PHP. It’s my first time using either, and I want to write down what I’ve been thinking along the way.

React, a Library for Building Screens

I was a frontend developer building services in React at a company. These days, with AI’s help, I’m not limited to the frontend, and I tend to build whatever the company needs, a bit like a handyman. Still, “Frontend Engineer” was printed on my business card for a long time. Since becoming a developer, I’ve used React to learn how to design UI as components, and I’ve felt firsthand how much productivity declarative rendering gives you. A simple model where the screen follows the state is clearly progress over the days of manipulating the DOM directly with jQuery…

But after years of working almost exclusively in React, I found myself, somewhere along the way, thinking: I’d like to stop using React for a while. Building this new blog with WordPress and PHP, which I’d never touched before, gave me a chance to think it through. In this post I want to share some very personal thoughts on how choosing React as the frontend stack has become something like the industry’s default.

When React first introduced itself, it described itself as “a JavaScript library for building user interfaces.” I remember quite a debate over whether React is a framework or a library, but it clearly began as a library, with a clear role: draw the screen in the browser based on a virtual DOM, and update only what’s needed when the state changes. Back then, things like routing, data fetching, and server rendering weren’t React’s concern.

Performance and SEO Pulled Rendering Back to the Server

The problem starts with a structural limit of client-side rendering (CSR). A CSR single-page application sends the browser what is effectively an empty HTML document plus JavaScript bundles. The real content only exists after the browser has downloaded, parsed, and executed those bundles.

The specifics of why that hurts loading are worth spelling out. While parsing HTML, a browser can start downloading JavaScript in parallel as soon as it sees a script tag, and its preload scanner can even find and request resources ahead of the main parser. The trouble is when the page’s core content, or the API request that fetches it, isn’t discovered until after the application’s JavaScript has run. The component tree gets built only after the script executes, the API request starts only then, and the content can be drawn only after the response arrives. Each step (run JavaScript, request data, receive it, render) waits on the one before it.

That’s why LCP (Largest Contentful Paint) suffers, and “JavaScript is slow” alone doesn’t explain it. The real problem is a structure in which core content depends on JavaScript execution and client-side API calls. Also, downloading JavaScript is only the start. The browser still has to parse, compile, and run it. On a phone with a slow CPU, that cost is even bigger, and on a truly ancient device, you can see the page but clicks and taps do nothing while JavaScript hogs the main thread.

Search visibility is subtler. Googlebot can run JavaScript with a Chromium-based renderer. But if a CSR page’s core content isn’t in the initial HTML, Google has to go through its rendering stage to see it. That rendering happens in a separate queue, so it can take a while for a page to get there. When a server responds with finished HTML, a crawler finds the content in the HTTP response itself. With CSR, there are extra steps: find the JavaScript, render it, and then find the content in the result. And not every crawler has a renderer as capable as Googlebot’s. Many link-preview crawlers don’t guarantee JavaScript execution, which is why Open Graph tags or titles injected after JavaScript runs sometimes go unrecognized.

So for services that depend on content and search traffic, putting the core content in the initial HTML, through SSR, SSG, or prerendering, became an important option. The React ecosystem responded by extending its reach from the UI toward the server.

React 18: From UI Library to Web Architecture

To understand how React moved toward the server, it helps to walk through its releases over the last five or six years. React 17 had virtually no new features. As the React team put it, it was a release with “no new features,” whose main purpose was to lay groundwork for gradual upgrades. Moving event delegation from document to each root container, so that trees from different React versions could coexist on one page, was part of the same effort. Version 17 itself was quiet, but that quiet was preparation for the sweeping changes in 18.

The most fundamental change in React 18 was concurrent rendering. createRoot, the new root API that replaces ReactDOM.render, is the entry point to React 18’s new renderer and features. Before that, React’s rendering was synchronous: once it started, it couldn’t be interrupted, and the browser couldn’t do anything else until it finished. With concurrent features like startTransition and Suspense on top of createRoot, React can prioritize rendering work and pause low-priority work to resume later. useTransition and startTransition are how you get there. Wrap a non-urgent state update in startTransition, and React pushes that render to the back of the line and responds to user interaction first.

Automatic batching was another major change. Up through React 17, only state updates inside event handlers were batched. Updates inside setTimeout, Promise callbacks, or native event handlers each triggered a separate render. From React 18, updates in those asynchronous contexts are batched automatically. It sounds minor, but it’s a real performance improvement because it eliminates unnecessary renders.

Still, the most significant change in React 18 was on the server. React 18 introduced new server rendering APIs, renderToPipeableStream and renderToReadableStream. The old renderToString waited until the entire page’s HTML was ready and sent it all at once. The new APIs can stream parts of the page as they become ready, using Suspense boundaries as the unit. While the server waits on slow data, it can already send the header and main content, then stitch in slower parts like comments or recommendations as they finish. The browser can start rendering in the order the pieces arrive, without waiting for the full HTML.

Then there are React Server Components (RSC). RSC actually predates React 18. The React team first shared its RSC research at the end of 2020, and when React 18 shipped in 2022, the feature was still experimental. If React 18 formally provided the foundation for streaming SSR and Suspense-based server rendering, RSC reached real applications first through frameworks like the Next.js App Router and only became a stable React feature with React 19. Concurrent rendering changed how rendering works on the client; RSC redefined where components themselves run. Together, these changes mean React can no longer be described as just a “UI library” that runs in the browser.

React still calls itself a UI library. But its current official docs also describe a “full-stack React architecture” and recommend using a framework when you start a new app instead of assembling React yourself. A tool that emerged when jQuery-style direct DOM manipulation was the norm has, over a decade or so, grown opinions about server runtimes, caching strategies, bundle boundaries, and data fetching patterns. The name “library” remains, but the scope of the architecture proposed around it is hard to compare with the early days.

There’s an important piece of context here. RSC was published as research before React 18, but React itself didn’t ship it in a form you could use in production right away. Because RSC is a component model spanning server and client, running it for real needs a framework that integrates the server runtime, routing, bundling, and streaming. Next.js took on that role first and most aggressively. Vercel, which makes Next.js, worked closely with the React team to ship RSC in production through the App Router. And React’s current docs now recommend a framework for new projects rather than building from React alone, with Next.js listed first. As a result, it’s become hard to talk about modern full-stack React, especially RSC-centered architecture, without talking about Next.js.

Why Next.js Feels So Complicated

By “complicated” I mean mainly the model of the Next.js 13–15 App Router era, which is what I used at work. Since then, Next.js 15 changed the default caching behavior of fetch and Route Handlers, and Next.js 16 introduced a new model built around Cache Components and use cache, where caching is opted into explicitly. Interestingly, those changes themselves show how confusing the early App Router’s implicit caching was for developers.

What struck me most about working with Next.js wasn’t simply that it’s complicated. It’s that understanding what your code means and understanding how that code behaves inside the Next.js system become two completely different things. Besides your own business logic, you need to hold the framework’s rules for deciding how to render, the layers of its cache, and where each component runs in your head.

The App Router is not a simple router, despite its name. Think of the declarative routing in React Router, which I used a lot in the past: it connected URLs to components, and that was it. In the Next.js App Router, the folder structure does more than define URLs. layout.tsx defines UI that child pages share and that persists during client-side navigation. loading.tsx creates a Suspense boundary for the route segment, and error.tsx handles the error boundary. On top of those come template.tsx, not-found.tsx, route.ts, parallel routes, and intercepting routes. Each file isn’t just “the screen for this URL” but a piece responsible for a different stage of the rendering lifecycle, and they all combine.

What developers most need to watch is how the rendering strategy gets decided. Suppose you have a server component that just fetches data. If any child component calls cookies(), the whole route flips from a static page generated at build time to a dynamic page rendered on every request. Locally, the code means something simple: read a cookie. Within the Next.js architecture, that one line changes the route’s rendering strategy and even determines whether the Full Route Cache applies. The more of these non-local effects there are, where a line’s local meaning differs from its meaning in the whole system, the harder it is to understand behavior just by reading the code.

The caching strategy added to the confusion. The App Router’s cache can’t be understood as a simple “cached or not.” According to the Next.js docs, there are four layers. Request Memoization is a React-level optimization that prevents duplicate requests when the same fetch call is made from several components during a single server render, and it disappears when the request ends. The layers that outlive a request are the Data Cache, the Full Route Cache, and the browser’s Router Cache. The Data Cache persists across server requests and reuses the same data across many users’ requests. The Full Route Cache stores the rendered output, the HTML and RSC Payload, and is a separate layer from the Data Cache, although the two affect each other. The browser’s Router Cache keeps the RSC Payload of routes you’ve visited or that have been prefetched in memory.

So when you’ve updated the database but the screen still shows old data, you have to track down which of these three layers is holding the stale value. Updating data no longer ends with changing a row in the DB. It includes deciding which cache to invalidate, whether to use revalidatePath or revalidateTag, and whether the client also needs router.refresh().

Early in the App Router’s life, caching was aggressive in pursuit of performance. A server fetch with no options behaved like force-cache, and GET Route Handlers could be cached by default under certain conditions. When you wrote data-fetching code, you couldn’t tell from the code alone whether it was fetching fresh data or reading data Next.js had stored. The big change to these defaults in Next.js 15 was effectively the Next.js team acknowledging that its performance-first defaults clashed with developers’ intuition. It’s a case showing that the early abstractions and defaults weren’t stable enough from a developer-experience standpoint.

Hosting deserves a mention too. Next.js officially supports self-hosting, and next start gives you most features: image optimization, Middleware, ISR, streaming. The trouble comes with scale. When you run multiple instances, each server has its own local cache, so an instance has no way of knowing that another one updated something. If you need cache consistency, you need a custom cache handler backed by shared storage like Redis or S3, and if you put Nginx in front, you have to adjust its buffering settings for streaming to work properly. That doesn’t mean Next.js only works on Vercel. It means the infrastructure complexity the framework was hiding becomes your team’s responsibility again the moment you deploy on your own infrastructure.

Taken individually, each idea in the App Router is quite logical. Caching static pages makes them fast. There’s no reason to request the same data repeatedly from several components. RSC cuts client bundles, and streaming keeps one slow piece of data from delaying the whole page. The problem is that when they’re combined, a developer has to keep all of this in mind: Is this component server or client? Is this route static or dynamic? Which cache layer does this fetch land in? Which cache needs invalidating when the data changes? How do I keep caches consistent across multiple instances? And once you’ve understood all that, you look back at the page you’re building and it may turn out to be a company intro, a blog, a service page, and a contact form…

The Long Way Back to Where We Started

Going through all this complexity, one basic question kept coming back to me. If what I need is finished HTML from the server, why not use the traditional way of building the web, which worked like that to begin with?

The web’s basic model is simple. The server answers a request with an HTML document, and the browser renders it. That HTML might be a static file, or it might be generated per request by a server program like PHP. You add JavaScript only where you need interaction, and search engines read the finished document without any extra machinery. We moved away from that model toward drawing everything on the client, and now, to fix the problems that caused, we’re heading back to the server. We just came back carrying a far more complicated structure.

A well-known cost of traditional React SSR is hydration. Even when the server builds and sends HTML, the browser has to reuse the server-built DOM, run each component’s rendering logic again in JavaScript, attach event handlers, and reconcile it all with React’s tree. SSR doesn’t remove JavaScript; it generates HTML to show before the JavaScript runs. The screen appears quickly, but it takes a while before it’s actually interactive, and if the server and client render results differ even slightly, you get a hydration mismatch error.

I don’t want to dismiss this journey, though. As we saw, React expanded toward the server because of many real problems, and for services that had those problems, the solutions have clearly been valuable.

Still, looking back, React’s path has an interesting back-and-forth. It left the traditional approach, where the server builds the HTML, for drawing everything in the browser. Then, to fix the performance and search problems that caused, it went back to the server, this time carrying a much more complicated structure: server components, server functions, caching strategies, and an execution boundary between client and server that you have to decide on together. After a very long detour, aren’t we rediscovering, in a far more complicated form, the old structure of the web, where the server builds the HTML and sends it?

Does a One-or-Two-Page Site Need React?

I’ve often seen React chosen as a matter of course even for static sites that are just a page or two, such as a company intro or an event landing page. The developer then has to set up a bundler, manage a node_modules folder holding hundreds of packages, and build deployment pipelines. Of course, the size of node_modules doesn’t ship to users. But sending the React runtime and hydration JavaScript to a page that plain static HTML would have served perfectly well adds download, parsing, and execution cost. More dependencies also mean more work keeping them updated and compatible. The complexity of the toolchain and runtime grows out of proportion to the problem being solved.

Security is similar. When people criticize technologies other than React, security vulnerabilities often come up, but the React ecosystem isn’t free of them either. The npm ecosystem rests on a huge web of transitive dependencies, and supply-chain attacks like package takeovers and malicious code injection have happened repeatedly. As React expanded toward the server, its attack surface expanded too. In 2025, CVE-2025-55182 was disclosed, which allowed unauthenticated remote code execution in how React Server Components handle server function payloads. Once you run code on the server, you take on the security responsibilities of server development. Security has to be managed in any technology, and the simpler the structure and the fewer the dependencies, the less there is to manage.

Above all, what I found stifling was the habit of dismissing anything other than React as “legacy.” Companies stick with React for practical reasons: a rich hiring market, continuity with existing codebases, shared knowledge across the team. I understand that kind of judgment. But when organizational inertia starts to stand in for technical judgment, we stop looking at the problem and start looking at the tool. The history of web development has moved in cycles, from server rendering to SPAs and back toward the server. Today’s standard becomes tomorrow’s legacy, and yesterday’s approach gets rediscovered. That’s the pattern in this field.

Recently, there’s also a clear trend toward embracing server-centered rendering again. Astro ships most of a page as static HTML and hydrates only the regions that need interaction, an approach it calls islands architecture. htmx lets you swap parts of a page by talking to the server through HTML attributes alone, and Rails’ Hotwire and Laravel’s Livewire likewise aim for a setup where the server builds the HTML and the client does as little as possible. All of them revive the philosophy the web had from the start: the server builds the document, and interaction is added only where it’s needed.

Vanilla JavaScript in the Age of AI

After all this thinking, I’ve decided that in my personal projects I’ll use vanilla JavaScript where I can and reach for React only when it’s truly necessary… Or rather, it’s less that I’ll use vanilla JavaScript wherever possible and more that I won’t haul a framework along, with all its weight, for simple jobs that don’t need one.

Two changes made this choice possible. First, the web platform itself has matured. Many features that once required a framework or library are now browser standards. ES modules let you split code without a build step, fetch handles asynchronous requests, and template literals let you assemble markup. The <dialog> element handles modals, Web Components provide reusable components, and the View Transitions API supports page transition animations. CSS has gained the :has() selector, container queries, and native nesting, absorbing much of what used to require JavaScript.

Second, of course, is the rise of AI. Writing features in vanilla JavaScript used to mean hand-writing a lot of repetitive code, and to avoid that labor I leaned on frameworks. AI has cut that cost dramatically. Code that runs straight in the browser with no build step is easy to debug, and there are no dependency updates to fight.

I do want to be clear about the limits of this approach. As an application grows, you end up solving state management, efficient DOM updates, and communication between components yourself, and it’s easy to create an undocumented, ad hoc framework of your own (one you never meant to build but that somehow became a framework anyway). If AI-generated code piles up without a consistent structure, maintenance actually gets harder. In team development, the shared rules and vocabulary a framework provides are still very valuable. For me, vanilla JavaScript is simply a way to strip unnecessary complexity out of small projects.

My First Time with WordPress and PHP

And now I’m using WordPress and PHP for the first time, to build my personal blog.

What surprised me first was the size of the ecosystem. WordPress is said to power more than 40% of all websites. Most of what you need to build a blog already exists as a theme or plugin, and there’s a vast amount of documentation and community knowledge.

Looking under the hood, I also found the design quite practical. WordPress’s extensibility is built on its hook system. With actions, which run code at specific points, and filters, which transform values before they’re output, you can change almost any behavior without modifying the core. The template hierarchy picks a template file according to the type of page requested, following clear rules, so you can easily separate the design of different pages just by file naming. You can query posts with WP_Query and expose content to the outside through the built-in REST API.

What I find interesting is that part of WordPress’s block editor is built with React. React is used for the editing screen, where interaction is complex, while the pages visitors see are rendered by PHP on the server as finished HTML. WordPress builds HTML on the server by default, and you can add client-side JavaScript for specific blocks or features that need it. I think this is a good example of putting each tool in the right place.

For a content-focused site like a blog, this structure is very natural. When you publish a post, the server responds with finished HTML, and search engines read that document as is. You don’t need to agonize over CSR versus SSR strategies to make the body text visible to search engines. The result I was trying to reach by going the long way around through React and Next.js, WordPress has provided as default behavior all along.

PHP Isn’t Legacy

Many web frontend developers write PHP off as legacy, but having worked with it now, I can’t agree so easily. I suspect much of that reputation comes from memories of the PHP 5 era: code with HTML, SQL, and logic mixed in a single file, and frequent security incidents.

Today’s PHP is very different from the PHP of the past. PHP 7 redesigned the engine and greatly improved performance, and PHP 8 introduced a JIT (just-in-time) compiler, union types, match expressions, named arguments, and attributes. Later versions added enums, readonly properties, and Fibers, and the language has kept modernizing. With strict type declarations and static analysis tools, you can write quite robust code, and the package ecosystem around Composer, along with mature frameworks like Laravel and Symfony, is solid.

Above all, in a traditional PHP-FPM web application, user-space application state has a per-request lifecycle by default. When a request ends, the next one starts from a clean slate. Compared with servers that keep state in a long-running application process, there’s less to worry about regarding state shared between requests. Of course, FPM workers themselves are reused across requests, and some state, like OPcache and persistent connections, outlives a request. Long-running PHP runtimes like FrankenPHP and Swoole change that assumption too. But in a traditional PHP deployment, the language doesn’t require an application build step, so simply deploying the source code is enough to run in production. I’d call that not outdated, just simple.

Of course, using WordPress doesn’t solve everything. Most of the vulnerabilities disclosed in the WordPress ecosystem are found in third-party plugins and themes rather than in the core. Installing plugins carelessly raises both performance costs and security risk, so you need to choose only what you need, choose carefully, and keep it updated. But that’s much like keeping up with npm dependencies when you work in JavaScript or TypeScript. In any ecosystem, the moment you pull in someone else’s code, you take on responsibility for it.

Choose Tools to Fit the Problem

Let me say it again: I’m not arguing that nobody should use React. For a dashboard with intricately entangled state, a tool where many users collaborate in real time, or a web application with constant interaction and no page transitions, React is still an excellent choice. Just as part of WordPress’s editor is built with React, there are places where React shines.

All I want to say is that you should choose the tool to fit the problem, not bend the problem to fit the tool.

For a content-focused blog, WordPress may be the most reasonable choice. For a static site of a page or two, HTML, CSS, and a bit of JavaScript are enough. If a content site needs partial interaction, consider options like Astro or htmx. If you’re building a complex web application, that’s when you reach for React. Making that call well means knowing many tools and being able to evaluate each of them without bias.

Having worked with React professionally, I can see WordPress’s and PHP’s strengths more clearly now. And having used WordPress and PHP, I can see more clearly when React is truly needed. You don’t have to give up either one. Put each in its proper place.

I’d like to see a development culture where “What would suit this problem?” comes up more often than “Who uses that anymore?” My hope is that we choose technology based on the problem we actually need to solve, not on what’s fashionable.