Sorting out JavaScript UI Paradigms
(Let me go out of my way at the beginning to acknowledge that this is an apples-to-oranges comparison in many ways.)
A friend recently managed to overcome my reluctance to experiment in React-based UI development, and today I sat down with Next.js' "React Foundations" course on my way to their real starter tutorial. My (still reluctant!) hypothesis going down this road is that I will be able to prototype much faster in an opinionated and popular framework like Next.js, and, since I'm not interested in becoming a web developer anyway, the less time spent on UI the better. It's a painful step for me nonetheless, since my bias is towards a minimalist approach. (See: how Energetica is coded.)
In the process of coding up the below extremely simple widget along with the tutorial in React and Next.js, I decided to indulge my bias by simultaneously coding up "Vanilla JS" in functional and imperative styles. This was initially an exercise to prove to myself how silly React and Next.js are, but turned into a more useful learning exercise for me, working through which patterns belong to the imperative style, which to the functional style, and which are "orthogonal" to either style. I figured I may as well write up a summary of the results to help my future self.
The Four Approaches
Each implementation achieves exactly the same result to the user's eyes, but the code behind them is slightly-to-greatly different.
- Vanilla JS (Imperative): Less abstract and more direct DOM API invocation.
- Vanilla JS (Functional): Purer functions and immutable state.
- React (Browser-loaded): Heavy, defined, functionalism. They call it "declarative".
- Next.js: React enchilada with file routing and local NPM dependencies.
Here's the code:
Vanilla JS (Imperative)
Claude gives my code sample 9 out of 10 on the "imperative-ness" scale. The 1-point demerit is for the use of (organizational) helper functions. It is super clean, and the clear winner for this task. It also scales poorly, they say. (Although with AI coding assistants around to do grunt work, that may be less true than it was before.) Aspects:
- Rendering Strategy: It uses one-time direct rendering, with updates imperatively targeted to specific DOM elements
- Function Abstraction: Helper functions serve primarily for organization, not abstraction
- State Management: It uses mutable state, passed as direct references (which some might consider an anti-pattern, but I find it explicit)
- Separation of Concerns: There's less separation between state, UI, and event handling
- Event Handling: Events directly trigger state modifications
Claude says: The imperative approach is closest to how browsers actually work [presumably because we are relying on the very imperative DOM API at the end of the day]. There's minimal abstraction between the code and what the browser does, which makes it both easy to understand and fast.
Vanilla JS (Functional)
Claude gives my code sample 7 out of 10 on the "functional-ness" scale. There is a ceiling on how functional the code can be, since its reliance on the DOM API means somehwere there need to be side effects. There's a lot to like here, but the higher-order function 'createElement' makes me think "Why?". Clearly overkill in this case, but you can see how it would be more testable and scale better. Aspects:
- Rendering Strategy: It uses explicit re-rendering, with the whole DOM tree recreated on each update
- Function Abstraction: Helper functions represent higher-order components, not just organizational helpers
- State Management: State is passed directly to functions via arguments, preserving immutability by never modifying existing state
- Separation of Concerns: There's more separation between state, UI, and event handling
- Event Handling: Events trigger new state creation, not direct state modifications
Claude says: This is a "pragmatic functional approach adapted to the DOM environment". I like the sound of that!
(A note on the two Vanilla JS approaches: my decision to keep all state global and together is not related to the imperative or functional paradigms. It is "orthogonal".)
React
React introduces component-based architecture with what they call "declarative" UI rendering. You write what you want to get, and you don't care about how it's done. In this case, that appears to mean writing out a full pseudo-DOM in the HomePage component... seems like all the work of the Vanilla JS "imperative approach"; although, you do get reactivity.
Now, it is less code, so long as you ignore the 4MB of run-time imports at the top of the file. It's noticeably slower to run in the browser than the instantly-rendering Vanilla JS approaches. But it's also obvious how it would be easy to assemble a prototype out of someone else's component library.
When discussing it with Claude, there seemed to be a feeling that React is just a formalized and built out functional approach with its own syntax and extra patterns on top. Capable, but perhaps bloated.
Claude says:
- Rendering Strategy: Uses a virtual DOM to efficiently update only what changes, with declarative rendering where you describe the desired UI state rather than the steps to achieve it
- Function Abstraction: Components are first-class citizens that encapsulate both UI and behavior, with a clear hierarchy and composition model
- State Management: Uses React's useState hook for local component state, with reactive updates that automatically trigger re-renders when state changes
- Separation of Concerns: Components combine HTML structure, styling references, and JavaScript logic in a single file, but separate concerns through component boundaries
- Event Handling: Uses synthetic events that normalize browser differences, with handlers that typically update state rather than directly manipulating the DOM
Next.js
Next.js feels like bringing in the very heavy machinery to solve the simple problem in our example. Its file-routing and layout-file-magic complicate and do not bring benefits at this scale. This is clearly not a great tutorial to showcase Next.js.
Claude says:
- Rendering Strategy: Extends React's rendering with server-side rendering (SSR), static site generation (SSG), and incremental static regeneration (ISR) options
- Function Abstraction: Builds on React's component model while adding file-system based routing and specialized components like Image and Link
- State Management: Inherits React's state management capabilities while adding server-side state handling and data fetching patterns
- Separation of Concerns: Enforces stronger separation through its file-system based architecture, with clear conventions for pages, layouts, and API routes
- Event Handling: Maintains React's event system while adding server-side event handling capabilities for form submissions and other interactions
Conclusion
No surprise, I still don't like the frameworks! But, I recognize that each approach has its place:
- Vanilla JS (Imperative): Good for small, performance-critical projects or when directness is valued over abstraction
- Vanilla JS (Functional): Good for performance-aware projects where maintainability matters but you want to avoid framework overhead and dependencies
- React: Valuable in the UI for component reusability and its ecosystem
- Next.js: Supposedly useful when working with React and needing to go beyond just the UI. But didn't see it here.
How I Wrote This Post
I was able to go super quick on this post using Claude 3.7 via Aider. The code examples were all written into the same project directory, and it only took minimal prompting to have Claude write up a draft blog post. (You can see that original here.) I then re-wrote 98% of the words, but I did keep the overall structure. I did have to spend a while getting the code to display well inside my markdown-based SSG, which took a whole separate chain of back-and-forth with AI assistants; I hope that will pay dividends, though.