Thoughts

The Case for Vanilla JS in 2026

Every framework is a bet. You're betting that the abstractions someone else chose will continue to serve your needs, that the community will maintain compatibility with your version, that the build toolchain won't break between now and when you need to ship a critical fix on a Sunday night. Sometimes that bet pays off, especially if you have all the time in the world to wait between shipping commits, but there's a different bet you can make. One where you own the entire surface area, where every abstraction exists because you chose it, and where the only dependency that can break your deployment is the browser itself.

I've spent the past 16 days shipping an entire software ecosystem without a single framework, build step, or external dependency. Six production sites. Two game engines. 169 JavaScript modules. 535 commits. The chess engine alone supports 70 playable variants with a multi-depth AI opponent. Every project deploys by pushing to main. Every project runs on any static file server on earth. Every project can be ported to any language, platform, or runtime without untangling someone else's opinions from my architecture.

This is not an anti-framework position. It's a pro-portability, pro-ownership, pro-longevity position that happens to make frameworks unnecessary for a specific (and I'd argue common) class of work.

The Portability Argument

Here's the thing nobody talks about when they choose React, Vue, or Svelte: you're not writing JavaScript anymore. You're writing React. The mental model, the component lifecycle, the state management patterns, the JSX syntax. None of it transfers. Your React app cannot become a mobile app without React Native. It cannot become a desktop app without Electron. It cannot become a backend service without a complete rewrite. It cannot become a Python application, a Rust CLI tool, or a WebAssembly module. It's locked into the React ecosystem permanently.

Vanilla JavaScript has no such constraint. A function that takes data and returns a DOM node is comprehensible to any programmer in any language. The patterns are universal: fetch data, transform it, render it. A Python developer can read my chess engine's move generation and port it directly. There's no framework idiom to decode, no lifecycle hook to understand, no virtual DOM diffing to account for. The logic is the logic.

This matters more than most people realise. When I built the Moddable Chess Engine, I didn't know it would need an embed API. I didn't know it would need to run inside an iframe with postMessage communication. I didn't know it would need to be consumed by three different parent sites with different theming requirements. None of that required architectural changes, because there was no framework boundary constraining where the code could run or how it could be consumed. It's just JavaScript. It runs anywhere JavaScript runs.

Every other language can understand vanilla JS. Every other language can be built directly from it. That sentence sounds simple but its implications are profound.

The Constraint is the Feature

Without a framework making decisions for you, you're forced to make them yourself, and that's where the interesting architecture happens. When there's no component model handed to you, you design one that fits your actual problem. When there's no state management library, you discover that your state is simple enough to not need one, or you build a minimal solution that does exactly what's required and nothing else.

The Moddable Chess Engine's plugin system emerged from this constraint. Seventy chess variants need to coexist in one codebase without any variant knowing about any other. In React, you'd reach for context providers, custom hooks, maybe a state machine library. In vanilla JS, I designed a hook-based registration system (registerVariant(key, config)) where each variant is a single file declaring its rule overrides. The engine's core never changes. Variant #71 takes minutes to add, not hours.

That architecture isn't possible in a framework. Or rather, it's possible, but you'd be fighting the framework's opinions the entire time. React wants you to think in components and props. My problem isn't a component tree. It's a behaviour composition system. The framework's abstractions would be overhead, not help.


The Plugin Architecture That Proves It

Let me go deeper on the chess engine, because it's the strongest evidence I have. 70 variants is not a trivial extension problem. These aren't skins or configuration tweaks. They're fundamentally different games. Atomic Chess explodes pieces in a 3x3 radius on capture. Crazyhouse lets you drop captured pieces back onto the board. Medusa Chess freezes pieces in line of sight. Each requires different move generation, different victory conditions, different board mutations.

The vanilla JS constraint forced me to design a tiered hook system rather than reaching for a framework's built-in extension model:

  • Tier 1 — rule overrides. A variant declares its board size, piece layout, and simple configuration flags. Standard Chess is 12 lines. Racing Kings adds noCheck: true and a custom victory condition. No engine changes required.
  • Tier 2 — engine extensions. Hooks like moveFilter let variants modify the legal move list. Dice Chess rolls before each turn and restricts which pieces can move. Displacement Chess tracks piece history via pieceData. Small surface area, powerful composition.
  • Tier 3 — effects, mutations, and action moves. beforeMove intercepts captures (Atomic's explosions). afterMove mutates the board post-move (Crazyhouse's piece drops). init sets up variant-specific state (hands, cooldowns, immunities). The engine provides the primitives; the variant composes them.

Here's what Standard Chess looks like as a plugin:

MCE.registerVariant('standard', {
  label: 'Standard',
  group: 'Classic',
  rows: 8, cols: 8,
  description: 'Classic FIDE chess. Checkmate the opponent\'s king to win.',
});

Here's the shape of something like Crazyhouse, a Tier 3 variant with piece drops, hand management, and board mutations:

MCE.registerVariant('crazyhouse', {
  label: 'Crazyhouse',
  rows: 8, cols: 8,
  init: function(g) { g.hand = { w: [], b: [] }; },
  moveFilter: function(g, moves) { /* add drop moves */ },
  afterMove: function(g, move, undo) { /* place from hand, capture to hand */ },
  undoAfterMove: function(g, move, undo) { /* reverse hand changes */ },
});

Every hook is a plain function. No decorators, no lifecycle methods, no dependency injection. The engine calls the hook if it exists, skips it if it doesn't. Variants compose by declaring only the hooks they need. The contract is explicit: you get the game state and an undo object; you mutate the board via MCE.mutateBoard() so the engine can track reversibility. Violate the contract and the engine throws. Loudly, with a useful message.

This is what "owning the full surface area" actually buys you. I didn't have to ask whether React's reconciler would play nicely with board mutations triggered inside a hook callback. I didn't have to work around a framework's state management when a piece drop changes both a hand array and the board simultaneously. I didn't have to fight a virtual DOM that wants to own all rendering when my SVG board renderer needs pixel-level control. Every decision was mine to make, and every decision served the problem rather than the framework.

The AI system makes the same point differently. A Web Worker running iterative deepening search with transposition tables and quiescence search doesn't map onto any framework's component model. It's a pure computation boundary. The main thread posts a position, the worker posts back a move. In a framework-heavy codebase you'd need escape hatches, refs, and effect cleanup to manage that lifecycle. In vanilla JS it's just worker.postMessage() and an event listener. Eight lines of glue, not eighty.

The result: 70 variants, five difficulty levels, variant-specific AI evaluators, opening books, and a postMessage embed API. All in ~2000 lines of core engine code with zero dependencies. A framework would have given me an opinions tax on every one of those features. Vanilla JS gave me a blank canvas and let the architecture emerge from the problem.


The Deployment Tax

Every framework introduces a build step. That build step introduces a toolchain. That toolchain introduces configuration. That configuration introduces environment-specific behaviour. Suddenly you need CI/CD pipelines, artifact management, staging environments, and a deployment process that takes minutes instead of seconds.

My deployment process: git push. GitHub Pages picks up the change. Cache-busting query strings ensure browsers get the new version. The entire ecosystem (six sites, two engines, all supporting infrastructure) deploys in under ten seconds. There is no build. There is no artifact. The source code is the deployment. Three of those 16 days were spent camping in the Yorkshire Dales, shipping over 3G. The architecture didn't notice.

This isn't a trivial convenience. It's a velocity multiplier. When you can ship every forty minutes without ceremony, you iterate differently. You don't batch changes. You don't wait for build pipelines. You don't coordinate deployments. You push, you verify, you move to the next thing. 535 commits in 16 days isn't possible when each one requires a build step.

The Data-Architecture Payoff

When you can't lean on a framework's data-binding, you're forced to separate data from presentation cleanly. Every project in the Moddable ecosystem stores content in JSON files fetched at runtime. Pages are templates that consume data. Adding a new chess variant means adding a JSON entry and a plugin file. Adding a new game to the hexmap framework means adding a terrain definition. Adding a new article to a blog means adding a JSON object.

This separation has a consequence that framework-coupled code never achieves: the data layer is API-shaped from day one. When (not if) these projects need a real backend, the migration is a URL change. Swap fetch('/data/variants.json') for fetch('https://api.moddable.games/variants'). The consuming code doesn't change. The templates don't change. The rendering logic doesn't change. You cannot get this property from a framework that couples data-fetching to its component lifecycle.

When Frameworks Are Correct

This is not a blanket position. Frameworks earn their complexity when the problem demands it: large teams that need enforced conventions, applications with deeply nested interactive state, projects where the framework's opinion is the architecture you'd build anyway. If you're building a real-time collaborative editor, you want something managing the DOM diff for you. If you're building a design tool with undo/redo across a complex object graph, a state management library is worth its weight.

Most things on the web are not those things. Most things on the web are: fetch data, render it, let people interact with it. For that (which describes marketing sites, documentation, portfolios, content platforms, embeddable tools, and even reasonably complex game engines), a framework is overhead you pay for in build complexity, deployment friction, and architectural lock-in without receiving proportional value in return.

The Hiring Signal

I've interviewed a lot of senior developers over the years. The pattern is consistent: candidates who only know React can only solve problems that look like React problems. Ask them to build something outside that paradigm (a canvas-based game engine, an embed API, a data pipeline) and they reach for the framework anyway, bending the problem into a shape React can hold rather than solving it directly. They've internalised someone else's abstractions so deeply that they've lost the ability to think below them.

The developers I hired were the ones who could do it both ways. They understood frameworks (often deeply) but they could also write vanilla JS that solved the problem on its own terms. They understood why a virtual DOM exists, not just how to use one. They could evaluate whether a given project actually needed React's reconciliation model or whether a simpler approach would serve better.

Only those developers got the luxury of choosing for themselves what was needed. Everyone else had already made the choice by default, because React was all they knew. That's not seniority. That's dependency.

When I build teams, I want people who can assess a problem on its own terms and select (or reject) tools accordingly. You can't do that assessment if you've never built anything without the tool. The vanilla JS constraint isn't just an architectural preference. It's a hiring signal and a professional standard.

The Longevity Bet

jQuery peaked and declined. Angular 1 was abandoned for an incompatible rewrite. React is sixteen years in with increasing competition from signals-based alternatives. Every framework has a half-life. The JavaScript that runs in a browser today will still run in a browser in ten years, because browsers don't break backwards compatibility. That's the real bet I'm making.

The Moddable Chess Engine will still work in 2036 without a single line changed. A React app from 2016 will not.

When I look at the projects I've built (the ones that have lasted, the ones that survived company pivots and team changes and technology shifts), they all share one property. They're built on the platform, not on an abstraction over the platform. They own their entire surface area. They can be understood, ported, maintained, and extended by anyone who reads JavaScript. Not just anyone who reads React, or Vue, or whatever comes next.

The constraint isn't a limitation. It's the architecture, and the architecture is what ships.