2025-09-16, a Tuesday

React is fine

programming html5

The JavaScript ecosystem is notorious for birthing a new web framework every day, and React apps are notorious for consuming memory, producing large builds, and growing ugly in large codebases. Yet despite the plentiful alternatives purporting to solve each of React’s downfalls, React apps are ubiquitous today, found pretty much on every web page beyond the simplest static sites, and used by almost every front-end team in companies. This is partly because it is difficult to switch to other frameworks when React benefits from its network effects.

Personally, I think React is fine. It offers a compelling strength that justifies why you wouldn’t just use vanilla JavaScript, and I don’t think its weaknesses are disastrous to call the React monopoly a terrible tragedy.

In this Longer Tweet, I yap a bunch about why I switched to React in the first place, then I go over some minor pain points I have with React.

My history with React

For a good portion of the time I’ve spent making websites, I’ve used vanilla JavaScript and direct DOM manipulation. For example, to add a heading to the page, I’d do

const heading = document.createElement('h1')
heading.textContent = 'Hello!'
document.body.append(heading)

or perhaps

document.body.append(
  Object.assign(document.createElement('h1'), {
    textContent: 'Hello!'
  })
)

I was making many small self-contained web pages, so I tried to avoid frameworks where possible out of practical concerns.

Many frameworks start their tutorials under the assumption that you will be making one large production-ready codebase, so they’ll recommend some bloated template like create-react-app1—mind you, I was dealing with this around 2016, when most web frameworks were just starting to mature. While I could tolerate each web page living in its own subdirectory, each template would also offer its own package.json, and with that, its own several gigabytes of node_modules. pnpm was too new around that time, and the laptop I was working on was persistently short on storage, often with less than 1 GB left. If I made a React project for every little web project I started, I would quickly rack up gigabytes of disk usage, probably from Webpack alone.

Reactive

To this day, I never learned React formally; I only figured it out by piecing through scratch-gui’s code, with its class components, Redux reducers, PropTypes, higher order components—all concepts that were deprecated by the time I got to author my own React code. But I got the gist of how it worked and more importantly, why it was used.

To that end, instead of learning to use React, I made my own knock-off, cheekily named Reactive. I declaratively defined the HTML in arrays—effectively a virtual DOM—then the library compares the declared elements to the previous state and makes corresponding changes to the real DOM. This was for rendering bell schedules in my Unofficial Gunn Web App (UGWA), replacing my previous 9th-grade approach of generating an HTML string and setting innerHTML.

I made Reactive largely because I felt that React and React DOM were bloated and would increase the bundle size of my app. However, it demonstrated one strength of React: it’s transparently just a library, so there’s no compiler that could magically transform my code into something beyond my grasp.2 When I use React, I have a rough idea of how it’s implemented under the hood.

Next.js

Outside of modding Scratch, I never really wrote my own React code until 2020, during Covid. Serena invited me to participate in some week-long online hackathon, where we made some health project. Ultimately, I don’t think we won anything, but it was the first time I had to write my own React components from scratch.

The project was quite different from scratch-gui. Instead of Scratch’s plain React, we used Next.js. It was interesting to see how simple components really were; there was no need to create a container for every component, like what seemed to be the case for Scratch. Yet, at the same time, it still felt like quite a pain to create a new component, since each time I wanted a new component, not only did I have to copy and paste the component template, but I also had to link it with a CSS module. Compared to my vanilla JavaScript projects, where I typically lump all my CSS into one file, this felt like a lot of bloat.

One curiosity from Next.js was how some file names had special meanings. In the pages/api/ folder, you can just create a JavaScript file that exports an HTTP request handler, and without importing it anywhere, it magically ends up as an API route on the backend. You can even use placeholders in the file name, like [groupid].js, to provide parameters. To me, this file-based routing felt like the compiler was holding my hand too much. The Next.js framework had magically set everything up for me, but it felt like I would quickly run into a use case not supported well by the framework, and that the framework would produce more bloat than my project would need.

This was one of my first times collaborating on a project with other people. As a high school student, I wasn’t confident about how a proper project should be structured. For instance, I would often see projects divided up into src/, assets/, docs/, etc., but I couldn’t find some definitive guide on how they should be used in a new project. So using React felt like the proper thing to use for a project, and in my next collaborative project—a UI for our friend’s graphing calculator—we opted to use create-react-app.3

Another hurdle that I was dealing with at the same time were bundlers. Scratch used Webpack, which was bloated and full of dependencies. I had the impression that Rollup was better—I’m not sure where I got this impression from; maybe it was just because Timothy was using it for his projects. However, Rollup came with plenty of pain points of its own. It’s bare bones, the complete opposite of Webpack’s bloat, but I found myself frustrated having to look up what options to configure and plugins to install to get essential features like Node module resolution, CommonJS support, and minification working. This made me want to avoid using bundlers when I could.

Deno and Preact

I’m not sure when I first learned about it—maybe it was from Timothy’s projects—but it turns out React, being just a library like jQuery, can be used without a bundler:

  1. You can import React and React DOM using a CDN, just like how you’d import jQuery

    <script
      crossorigin
      src="https://unpkg.com/react@18/umd/react.development.js"
    ></script>
    <script
      crossorigin
      src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
    ></script>
    
  2. JSX is just syntactic sugar for React.createElement, so the equivalent of this JSX

    <p>Hello!</p>
    

    in JavaScript is just

    React.createElement('p', null, 'Hello!')
    

    Alternatively, you can use @babel/standalone to put JSX in a script tag, load the entirety of Babel through another script tag, and have it transform and run the JSX in the browser.

So, on a few occasions, I would import React through a CDN, then painstakingly write out the JSX using function calls:

const { createElement: e } = React

function App ({ message }) {
  return e('p', null, message)
}

ReactDOM.render(e(App, { message: 'Hello!' }), document.getElementById('root'))

This was alright, but without JSX, the function calls became hard to read as they got increasingly nested. Because of this, I used React sparingly in my projects.

By the time I started university in 2021, Deno had risen in popularity. It’s meant to be a fresh approach at server-side JavaScript, and as someone who used to modern web features in vanilla JS, I was sold immediately. I saw plenty of benefits over Node:

  1. Deno mimics the web, with HTTP imports for libraries4 (no more node_modules!) and a preference towards using web APIs where possible, like supporting the Fetch API and returning Uint8Arrays, unlike Node Buffer.
  2. Deno has first-class TypeScript support5 and uses promises in its APIs, instead of most APIs still using callbacks like Node.
  3. Most relevantly for this Longer Tweet, Deno provides its own deno bundle6 command that bundles the entire module graph into a single module, all out of the box!

deno bundle was perfect for me. It didn’t require creating a configuration file or installing a library for each project. It was a simple CLI that, combined with Terser, met all my needs: TypeScript support, module resolution, and minification. Plus, the docs had an example of using JSX and Preact—essentially React but leaner—in Deno, so all the pieces were in the right place.

A Deno-based Preact app looked like this:

/** @jsxImportSource https://esm.sh/preact@10.17.1 */
/// <reference no-default-lib="true"/>
/// <reference lib="dom" />
/// <reference lib="deno.ns" />

import { render } from 'https://esm.sh/preact@10.17.1'

type AppProps = {
  message: string
}
function App ({ message }: AppProps) {
  return <p>{message}</p>
}

render(<App message='Hello!' />, document.getElementById('root')!)

Then, I could bundle and minify it with a simple shell command:

$ deno bundle index.tsx | terser --module > index.js

That was it. I didn’t need a webpack.config.js, nor did I need to npm install a bunch of packages.

I had been scraping our university’s course registration website with Deno, so when I wanted to present the data on a website, Deno was a natural choice. Rendering a web page from JSON is a perfect use case for (P)React, recursively generating components of the page out of a datum in the JSON. So, I made my UCSD classrooms website with Deno and Preact.

Back to Node

You’d think that with deno bundle, all my bundler troubles would be resolved. Alas, deno bundle got deprecated in 2023. By then, I had a number of small web projects bundled with Deno, but I knew that I couldn’t rely on it forever.

The deno bundle deprecation warning recommended, among others, esbuild. I tried it out, and it worked. Like deno bundle, esbuild doesn’t require a configuration file, instead accepting command line arguments, and like deno bundle, it comes with TypeScript and JSX support out of the box. However, it also can minify, produce source maps, import CSS modules, and start a local development server that rebuilds on the fly—the last one is pretty nice since I didn’t need to start an HTTP server separately, and if I reload too quickly, I think it stalls the request until it finishes building instead of serving a stale build.

It wasn’t a painless transition, though. esbuild was designed for the Node ecosystem, so it doesn’t have support for importing modules from a URL. Without deno bundle, there was little reason to keep using Deno’s type checker, which was less stable than VS Code’s native TypeScript language features. However, that meant I had to manually configure TypeScript, and it took a bit to figure out what values to use in tsconfig.json to get all my errors to go away—I kept running into bizarre Cannot find global type 'Array'. errors—but I eventually figured it out.

SunSET was my first new project built with esbuild and React, and I also made a proper GitHub Action to build and deploy the React app to GitHub Pages.7 It now serves as my template for any React apps I build in the future, like Doufu and QR.

Conclusion

I like React, but what holds me back from using it in all of my projects is the bundler. Because JSX is effectively necessary to use React, a build step is required just to deploy the web page to GitHub Pages. I didn’t like bundlers of the day, like Webpack and Rollup, because they’re bloated and require plenty of boilerplate. Now that I’ve found esbuild, that roadblock has been lifted; it’s straightforward to include a React app inside a monorepo of other web pages.

React qualms

Although I still use vanilla JavaScript for most of my small web page-sized projects, I see the appeal of React when it insists upon itself. It’s good for when I want many parts of the web page to automatically react to some new value, or when I already need a bundler to build TypeScript.

Beyond personal projects, React is also the framework in the front-end teams I’ve worked in, like at ACM at UCSD, Tesla, and TikTok, and of course it’s used in Scratch 3.0. So I’ve read and written plenty of React code for a variety of apps, and yet I sometimes see the same pain points arise from the monotony of creating yet another React component.

I know that there are too many frameworks out there that see issues with React and tried to tackle them, and I’ll admit I haven’t looked in depth at many of them—as said above, I personally limit my use of frameworks in general, and React is ubiquitous in the industry—but these could be what I’d be looking for when evaluating[^9] a web framework.

[^9] Or, god forbid, making my own.

useRef has no initializer function

For some reason, useState supports an initializer function, in case it’s expensive, but useRef does not.

React’s Rules of Hooks permit the mutation of the ref’s current value in the component body:

const context = useRef(null)
if (context.current === null) {
  context.current = new AudioContext()
}

However, this doesn’t work well in TypeScript. While in the above example it’s able narrow context.current’s type to AudioContext in the component body, it remains AudioContext | null inside function expressions like useEffect. This might be because of how useRef type signature is defined, however. If useRef were able to have an initializer function, then it’d be easier to convince TypeScript that current is never null.

Getting the latest value of a state

In useEffect and other callbacks, I’ll often want to use the current value of a state. However, doing so would require you to pass the state value to useEffect’s dependency list.

useEffect(() => {
  //
}, [TODO])
const [state, setState] = useState('...')
const stateRef = useRef(state)
useEffect(() => {
  stateRef.current = state
}, [state])

Using useEffect as a listener for state changes

Also, useEffect’s design of running when its dependencies change makes it a very attractive choice for performing side effects, such as saving data, whenever the state changes.8 This isn’t the best option, though, like in this example:

const [state, setState] = useState('...')
const [saving, setSaving] = useState(false)

useEffect(() => {
  setSaving(true)
  saveState(state).finally(() => {
    setSaving(false)
  })
}, [state])

return (
  <button onClick={() => setState('something else')}>Change the state</button>
)

This feels like a natural solution to saving the state whenever it changes. However, this isn’t optimal9. The useEffect runs after state is set, but it also sets the saving state in its body. This results in a double render, whereas the proper approach would place the callback directly after wherever the state is set:

setState('something else')
setSaving(true)
saveState(state).finally(() => {
  setSaving(false)
})

But this is not feasible if setState could be called in many places in the app.

hmm but couldnt you just wrap setstate in another function. and also it could be annoying to not be sure if setting state will cause side effects


react annoyances:

  1. I know create-react-app is deprecated, but my point still stands. There’s create-next-app, sv create, create-vue, etc. 

  2. I know the React Compiler exists now, but my point still stands. The React Compiler is meant to replicate the behaviors of the existing library, which presumably means that once released, you’d still be able to use the library without the compiler. 

  3. It isn’t worth noting, but a few months later I also worked on Clustr, which uses create-react-app. I feel like the lore is kind of funny. Basically, some people from my high school were working on a startup—one of them is a child of a tech bro—and wanted me to help them with the web version of their app. It’s just a wrapper around the spreadsheet of school clubs, since the school year was remote, but they managed to get the school admins to advertise it. Nonetheless, while I was implementing their design, I happened to also be updating the clubs list in UGWA—their direct competitor. UGWA’s club directory had more information, including the clubs’ promo videos, and was easier to search, if I do say so myself. Tragically, UGWA was only acknowledged after it had shut down

  4. Except importing unversioned URLs considered harmful, I guess, so they gradually began moving away from that model, first with import maps then first-class npm support. 

  5. While Deno became more like Node, Node became more like Deno, with top level await, built-in fetch. Like my argument that other web frameworks don’t provide enough benefits to leave behind React’s network effect, I’m similarly disillusioned about Deno now because it no longer provides enough benefit to compensate for leaving Node’s ecosystem. Deno ultimately was just a fad. 

  6. Which they later deprecated then removed in Deno 2. Though, at the time of writing, it seems they’re adding it back

  7. For the classrooms website, which still uses Deno 1.x to this day, it clones and pushes the build to the gh-pages branch of the repo. 

  8. Kind of like always_ff in System Verilog. 

  9. It also has a race condition if the state changes again while it’s saving, unless saveState is capable of locking or cancelling past invocations. 

See source and revision history on GitHub.

Write a comment