React apps without a build step: no node_modules, no webpack, no babel, no worries. Scalable architecture for free using the platform.

I’ve been a web developer for nearly a decade now and recently felt a certain contempt toward a platform I once truly adored. This has led me on a journey of discovery, which has altered my outlook on the future of web development.

Saying no to the status quo

For too long we, as web developers, have been in contention with web browsers and with JavaScript the language itself. But this need not be the case anymore. ES6 was a major update to JavaScript that included dozens of new features and inspired browser vendors to get together in order to manifest an evergreen world. A promised land where we can all build out our dreams and live happily ever after.

I ask that you forget what you know and take a few minutes to read this post in the hope that you too can be persuaded to liberate yourself from a toolchain conglomerate that once helped us be more productive, but is now almost certainly introducing unnecessary complexity, limitations, and overhead to our web apps and dev environments.

I am going to pick on two parts of the de facto build chain that have historically proven somewhat a bane to the development cycle of most projects. Not surprisingly, I am going to propose alternatives. But not some shiny new tools. Rather, that you try using nothing at all instead.

The minimalist approach

Starting from scratch is what we, as developers, love to do, so that is what we are going to do here. I want to demonstrate how to build up a modern dev environment (like create-react-app) just by using "the platform" and squeezing out every drop of potential from the latest language features.

As a child I used to say to my mother “Can I have this?” to which her response was always “Do you need it?”

I love create-react-app (so much so I redesigned the welcome screen for it) and have encouraged many people to use it over the years. It is indeed a best-in-class starter that is great for:

  • Learning React in a comfortable and feature-rich development environment
  • Starting new single-page React applications
  • Creating examples with React for your libraries and components

But it is not so great for (and I quote the create-react-app repository here):

  • Trying React without hundreds of transitive build tool dependencies

Which is exactly what I wanted to do. I’m the kind of person who, rather than checking in luggage on a flight, will try squeeze everything I need into my hand baggage allowance. It’s easier like that, fewer dependencies, fewer worries.

I try to take a similar approach with my apps. I do a lot of prototyping in my day job and have found pulling in 1044 separate dependencies, which amounts to around 200MB on disk, (a grim side effect of running npx create-react-app) for every demo is just not sustainable; my hard drive is only 128GB!

How to develop an app without Babel and Webpack

I said we were going to pick on two tools in particular in this post and now is when you get to find out what those two things are and why:

  • Webpack because of a new language feature called ES Modules that allow JavaScript to import other JavaScripts at run time.
  • Babel because ES6 is well supported now and we can rely on tagged template literals, which enable embedding non-standard JS syntax.

Ok. So, you are telling me we can have all the nice things we like–such as bundling, JSX, the latest language features, live reload etc., all for free, from the platform?

Yes, you can... and in essence, this is what it looks like:

import { React, ReactDOM } from ‘https://unpkg.com/es-react';
import htm from ‘https://unpkg.com/htm'
const html = htm.bind(React.createElement)

const Route = {/: React.lazy(() => import(./routes/home/index.js’)),*: React.lazy(() => import(./routes/lost/index.js’)),
}

ReactDOM.render(
  html`
    <${React.Suspense} fallback=${html`<div></div>`}>
      <${Route[location.pathname] || Route[*]} />
    <//>
  `,
  document.body
)

What's more, with this architecture there are no node_modules, and no package.json is required by default either. So, let’s step through the example code and determine exactly how this all works.

Modularising your application without a bundler

We all dream of our app fitting into a single file. When you come up with an idea you immediately think “How hard can this be? It is probably only 1,000 lines of code or something... that will fit into a single file, no problems.”

"Good authors divide their books into chapters and sections; good programmers divide their programs into modules." –Preethi Kasireddy

Unfortunately the reality is that nothing is as simple as it might first appear. Eventually you will want need to break up your code into separate files. Until now Webpack (or perhaps Rollup) has done the heavy lifting in this regard by "bundling" your code for you.

But whilst we have all been busy—maintaining bundler config files and debugging broken builds—some clever people over at ECMAScript have been baking this behavior into JavaScript the language itself.

Even better than that—browser vendors have actually gone and implemented it!

Screenshot from https://caniuse.com/#search=modules

ES modules are a super powerful concept—a monumental breakthrough for front-end development. Imports come in two different forms:

  • Static import is preferable for loading initial dependencies, and can benefit more readily from static analysis tools and tree shaking.
  • Dynamic import is useful in situations where you wish to load a module conditionally, or on demand; also known as lazy loading.

If we look at the example code we can see both dynamic and static imports are being used to load different dependencies:

import { React, ReactDOM } from ‘https://unpkg.com/es-react';

First, a static import is used to import React and ReactDOM straight from unpkg.com (which, if you didn’t know already, is a CDN that mirrors the NPM registry 1-to-1). This kind of import is synchronous and will block execution until the script has been resolved. It works with both relative paths—for example ./pages/home.js, and absolute paths like this one.

To make this work with React specifically, I created the package es-react (as currently Facebook doesn’t export a ES module build), which is hand-crafted but is absolutely synonymous with React 16.8.3.

React.lazy(() => import(./routes/home/index.js’))

Second, a dynamic import is used to pull in pages appropriate to the current window location. It seems the React team saw this language feature coming and have implemented React.Suspense and React.Lazy components to aid with code splitting at run time like this.

Employing a combination of static and dynamic imports like this gives you absolute control of what is loaded and when. You can read more about this approach in the React documentation.

Using the latest ES6 syntax and JSX without Babel

So we have modularised (bundled) our app, essentially for free, just by using the new import syntax (that was easy), and we live safe in that all evergreen browsers support imports (static at least; you will need to shim dynamic imports for Firefox and Edge).

But the create-react-app build chain does a lot more than that; it passes the contents of each file Webpack finds into Babel, whose (primary) job is to transpile any new ES6 syntax into ES3/5 syntax so the all browser family can parse it without error.

But guess what? While we have been busy waiting for our projects to build (transpilation is quite intensive and is probably taking up at least a few second of your life every time you hit save), clever people at Google, Microsoft, and Firefox have been busy baking in support for all the latest syntax into browsers.

Screenshot from https://kangax.github.io/compat-table/es6/

Nowadays, it is fair to assume that anywhere you can use ES modules, you can use most other ES6 syntax without transpilation. That includes cool stuff like const and let, async and await, array and object spread operators.

But what about JSX?! Surely that is not natively supported yet? No. You are right. That would be ridiculous because JSX is not real JavaScript. It is a domain-specific language created and maintained by Facebook. There are alternatives but it has proven popular enough for someone to go figure out how to make it work without a build step.

"I wanted to use Virtual DOM, but I wanted to eschew build tooling and use ES Modules directly." –Jason Miller

That person was Jason Miller who works for Google. He created a package called htm. The implementation takes advantage of another new language feature called Tagged Template Literals and you can use them in your project like this:

import htm from ‘https://unpkg.com/htm?module'
const html = htm.bind(React.createElement)

ReactDOM.render(
  html`<div>Hello World</div>`,
  document.body
)

First, htm (a function) is imported from unpkg.com using a static import—just like es-react was. Then it is bound to React.createElement as to create the factory function html, which can create virtual dom nodes in a shape that ReactDOM can comprehend. It will do all of this in a blink of an eye, at runtime.

You can read more about how it compares to transpiled JSX in the project repo.

Serving up static files with live reload

So we have our script but to bring it to life we are going to need something else—a development server! Historically create-react-app has provided a solid localhost static file server with livereload behaviors. So let’s try to replicate that (without introducing a node_modules folder):

$ npx servor

Run this command from the root directory of a create-es-react-app clone or any directory containing an index.html for that matter. The app should open in your preferred browser and will stay in sync with the codebase when changes occur. Magic.

I created the servor package to do this one job specifically. It is tiny and dependency free; it takes advantage of an ordinary keep-alive connection in combination with node fs.watch and an injected EventSourceAPI script listening on the client. The source is very approachable and I encourage you to go check it out!

The start of a revolution

So there you have it, a react app starter template with lazy loading routes and views written in JSX, and a live development server. All without node_modules, Webpack, or Babel. It turns out most of what we need to build scalable apps is achievable just by using the platform, which really puts the power firmly back into your hands and lightens your life (each create-es-react-app clone weighs 50KB).

Now, before you get your Webpack, Babel, or create-react-app sticker-coated pitchforks out, let me try to outline a few shortcomings of this approach. Some reasons why you might want to stick to the status quo (along with counter points):

  • There is no TypeScript or Flow support (could use comment types)
  • Server-side rendering is untried (give it a go if you are interested)
  • ES modules builds for packages are not as common (make a PR)
  • Firefox still doesn’t support dynamic import (use a ponyfill)
  • React suspense and lazy APIs are not final (but they will be soon)
  • Importing scripts from the Internet can be dangerous (be careful)
  • There is still no official way of modularising styles (we could use fetch)
  • Missing minification of styles or scripts (gzip works on unminified code too)
  • No centralised record of package versions (could employ static analysis)
  • No chunking could lead to many requests being made (http2 is here already)
  • Syntax highlighting within tagged template literals could be better

If you can think of any more, then let me know on twitter!

The landscape is changing

Of course whenever there is a fundamental shift in paradigms there is going to be resistance and people are going to find edge cases where the application of an idea is inappropriate. However, I have seen a lot of different architectures in my time as a developer and firmly believe that the simplicity of an ES module architecture is revolutionary and will be sufficient for the majority of use cases.

In the not-too-distant future

I predict we are going to see smarter servers that perform production-ready optimizations—perhaps bundle, transpile, minify then cache—at request time, or perhaps at deploy time.

That's all, folks!

Thanks for taking the time out of your day to read this and thank you to my employer Formidable for allowing me to spend time researching, experimenting, and writing about interesting topics like this.

Feel free to follow me on twitter @lukejacksonn