Building a TODO app without a bundler
Tags: javascript, webdev, react, showdev
Published: 2022-06-04 17:27:56

Do you remember the time before front-end frameworks and build tools, where you would sprinkle some JavaScript on top of your HTML to create interactivity? Code up your HTML documents, preview them in the browser without tools like Webpack, then push them to your webserver using FTP? 
I sure do. 👴

What if I told you, you can build modern web apps and have still have a smooth development workflow without any build tools?

In this article I'm going to implement the [TodoMVC](https://todomvc.com/) app without any build tools and only use native JS functions supported by evergreen browsers (sorry Internet Explorer, [it's time for you to leave](https://dev.to/ekeijl/internet-explorer-retires-on-june-15-what-can-a-developer-do-11d3)). 

I will use some libraries related to React, but you could write the app using anything you prefer (or no library at all). **What matters most is the fact that we simplify our development process by cutting out tools that are required to work with these modern day frameworks**. The starting point is just a HTML document with a `<script>` that initializes our app, while SPAs often start from an `index.js` *entry point* and try to control the document from there.

Here's the [source code](https://github.com/ekeijl/todomvc-htm-preact) and the end result:

{% codesandbox vyefvj initialpath=%23/all %}

# Single Page Apps

When building an interactive web app, developers usually reach for frameworks such as React, Angular, Vue, Svelte, to name a few. These frameworks are mostly abstractions and best practises to help you create modular code while staying productive. They all come with a set of supporting tools to smooth out the development process: translate modern JavaScript features to something all target browsers understand, manage dependencies, optimize the output code, etc.

These interactive client side apps are often [Single-Page Applications](https://developer.mozilla.org/en-US/docs/Glossary/SPA): a web application that loads a single document and then updates the page content using JavaScript by loading other modules and fetching data from a REST API.

Not every website needs to be a SPA, mind you. In fact, the approach below could be used in a good old multi-page website, where you sprinkle the JS on top of the page to create the interactive ToDo functionality.

# Goals

We are going to build a simple TODO application such as [this one](https://todomvc.com/examples/backbone/), which is fully client side and has a clear scope.

- Implement the TodoMVC app using [this specification](https://github.com/tastejs/todomvc/blob/master/app-spec.md).
- Use only native [ES6 browser features](http://kangax.github.io/compat-table/es6/).
- No build tools (Babel, Webpack, etc).
- We still want to be able to use NPM packages.
- Supports latest stable version of Chrome, Firefox, Safari, Edge.

# Why would you go buildless?

Let's start with the main reasons we still need bundlers in 2022:

- The NPM ecosystem is built around packages being able to run in [NodeJS](https://nodejs.org/en/), not primarily for the web. NPM packages are expected to use the [CommonJS format](https://nodejs.org/api/modules.html) to ensure everything is compatible with each other. Publishing a package using pure [ES Modules](https://nodejs.org/api/esm.html) would break that compatibility. Seems backwards, right?
- Packages use a short-cut method of importing other packages by their package name, without an extension (*bare imports*), e.g.: `import groupBy from lodash/groupBy` instead of `import groupBy from './node_modules/lodash/groupBy.js`. Tooling is needed to fix the module resolution.
- Bundlers take care of a lot of implicit stuff, like polyfilling missing features. A lot of NPM packages expect this stuff to just work.

[Pika](https://www.pika.dev/) is doing an awesome job at rethinking this whole process and it questions why we need web bundlers today at all. Check out this great talk:

{% youtube 2Wwx-lF5NhE %}

The reason to ditch all this tooling seems obvious: it simplifies development, because you only need to deal with native JavaScript. No tools to learn, no configurations to manage, no more waiting for your app to start up.

You get some additional benefits too:

- Your development environment is exactly the same as your production environment, which can make debugging easier.
- No security risk of installing third party code during development. NPM packages can basically run any code on your machine using post-install scripts.
- Modules are cached individually. Updating a single module means other modules stay cached. This is more of a hassle when using Webpack.

# Downsides of going buildless

- No pre-processing, so you lose access to tools like TypeScript, LESS/SASS (for CSS).
- No support for modular CSS yet. [Import assertions](https://css-tricks.com/css-modules-the-native-ones/) are far from production ready.
- No minification or tree-shaking of application code.
- Slight performance hit compared to loading bundled JS. Large JS files still compress better than smaller individual files. So there is some benefit in bundling all code into a single file. HTTP/2 might resolve some of that issue, but I haven't seen concrete numbers yet. See also [this discussion](https://github.com/lukejacksonn/perflink/issues/15).
- Managing module imports can become messy, resulting in long relative import paths `../../../module/subModule/component.mjs`. Webpack has [aliases](https://webpack.js.org/configuration/resolve/#resolvealias) to make your life easier. Luckily, JS [import maps](https://caniuse.com/import-maps) solve most of this problems and are available in all major browsers.

You win some, you lose some.

# Using third party libraries in a buildless setup

Just because we aren't allowed to use build tools, does not mean we can't use any NPM libraries. To load them, we have several options.

Content Delivery Networks (CDNs) are free online services that serve NPM packages over the network. Examples are [jsDelivr](https://www.jsdelivr.com/), [unpkg](https://unpkg.com/) and [SkyPack](https://www.skypack.dev/). We will be using these services to import the libraries we want to use.

You can import those packages using a script tag, for example:

```html
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
```

ES modules allow you to import directly from a URL:

```js
import groupBy from 'https://unpkg.com/lodash-es@3.10.1/collection/groupBy.js';
```

[Learn more about ES imports in this article](https://dev.to/open-wc/developing-without-a-build-1-introduction-26ao)

# Libraries for the buildless route

We are looking for libraries that use ES modules, so we can drop them into our app and use them like any other library.

- [Lit Element](https://lit.dev/) which builds on top of the web components standard. ([example app](https://dev.to/open-wc/on-the-bleeding-edge-3cb8))
- [Vue Single File Component loader](https://github.com/FranckFreiburger/vue3-sfc-loader) allows you to sprinkle Vue on top of any HTML document. ([example app](https://paulhammant.com/2021/02/16/buildless-sfc-vuejs-applications/))
- [HTM](https://github.com/developit/htm) - a library that lets you write components using JSX-like syntax using template string.
- [Symbiote](https://symbiotejs.org) - framework that allows you to write class based Custom Elements, focused on building complex widgets that you can then embed in other apps.

[Here](https://unsuckjs.com/) you can find a list that compares multiple no-build libraries.

## HTM, Preact & JSX

I feel very productive writing front-end UI components in React using JSX, so I wanted to have something similar for this app. After some googling I stumbled upon HTM, which promises JSX-like syntax without bundling, so I decided to give that a try. HTM plays nicely with [Preact](https://preactjs.com/) (a leaner version of React with only slight differences).

When using Preact without HTM, you would need create components similar to how you would call `React.createElement()`, namely:

```js
import { h } from 'https://esm.sh/preact';

const myH1Element = h('h1', null, 'Hello World!'); // <h1>Hello World!</h1>
```

HTM uses JavaScript's own [tagged template syntax](https://preactjs.com/guide/v10/getting-started#alternatives-to-jsx), so you can write JSX-like syntax:

```js
import { h, render } from 'https://esm.sh/preact';
import htm from 'https://esm.sh/htm';

// Initialize htm with Preact
const html = htm.bind(h);

const MyComponent = (props, state) => html`<div ...${props} class=bar>${foo}</div>`;
render(htm`<${MyComponent} />`, container);
```

For convenience, HTM provides a module that does this binding for you. You only need to import from the `preact/standalone` module:

```js
import { html, render } from 'https://esm.sh/htm/preact/standalone'

render(html`<${App} name="World" />`, document.body);
```

## State management using Valtio

[Valtio](https://valtio.pmnd.rs/) uses JavaScript proxies to wrap your state objects and track changes automagically. ✨

The state can be manipulated outside the React/Preact lifecycle too using vanilla JS. [Persisting the app state](https://github.com/pmndrs/valtio/wiki/How-to-persist-states) to `localStorage` is also trivial.

The library is light-weight and easy to work with. Valtio is certainly not required for the no-build app, but it felt like a good match for this setup.

# Implementing the TODO app

I would like to use a component based development approach without writing everything from scratch, so I decided to use [HTM](https://github.com/developit/htm) with [Preact](https://preactjs.com/). This allows me to write JSX-like syntax without a transpiler.

I won't dive too deep into the implementation itself, but you can find the source on [GitHub](https://github.com/ekeijl/todomvc-htm-preact).

## Getting started

Create an `index.html` file and add a `<script>` tag and point it to `js/index.mjs` - the starting point of the app:

```html
<!DOCTYPE html>
<html lang="en">
	<head>
		<title>No-build ToDo app</title>
	</head>

	<body>
		<script type="module" src="js/index.mjs"></script>
	</body>
</html>
```

We can import the CSS for our TODO app directly from a CDN like so:

```html
<link rel="stylesheet" href="https://unpkg.com/todomvc-common@1.0.5/base.css" />
```

In the `index.mjs` file we can `import()` any other modules that we need. From here we can start writing modular components as we would do when using React!

```js
// js/index.mjs
import { html, render } from 'preact';

import { Header } from './Header/index.mjs';
import { Footer } from './Footer/index.mjs';

const App = () => {
	return html`
		<${Header} />
		<section class="todoapp">
			<!-- etc -->
		</section>
		<${Footer} />
	`;
};

render(html` <${App} />`, document.body);
```

Beware that we need to write the full path, including extension, when importing a module - this is how ESM works.

## Importing third party modules

You might be wondering how we are importing `preact`, as we are doing on the first line:

```js
import { html, render } from 'preact';
```

How does our browser know how to resolve `preact`? We are using a so called *import map* to define a mapping of a dependency to a specific URL. This URL can point directly to a CDN where this library is hosted.

In our `index.html`, we define a script of type `importmap` where we map all our dependencies:

```
<script type="importmap">
{
  "imports": {
    "uuid": "https://esm.sh/uuid",
    "preact": "https://esm.sh/preact",
    "preact/compat": "https://esm.sh/preact/compat",
    "preact/hooks": "https://esm.sh/preact/hooks",
    "htm": "https://esm.sh/htm",
    "valtio": "https://esm.sh/*valtio?alias=react:preact/compat",
    "proxy-compare": "https://esm.sh/proxy-compare",
    "use-sync-external-store/shim/index.js": "https://esm.sh/use-sync-external-store?alias=react:preact/compat"
  }
}
</script>
```

In this case we are using [esm.sh](https://esm.sh/), which is a CDN for NPM packages. It bundles the library in the URL with all of its dependencies, so they are ready to use in the browser.

See [this article](https://blog.logrocket.com/es-modules-in-browsers-with-import-maps/) for more information about import maps.

### ESM.sh aliasing

We need to tell ESM.sh that it needs to replace all imports of `react` with `preact/compat`, which is Preact's [compatability library](https://preactjs.com/guide/v10/switching-to-preact/). We can do this by using the `alias` URL parameter:

```
https://esm.sh/valtio?alias=react:preact/compat
```

### Issue with `use-sync-external-hook`

There is an [issue](https://github.com/facebook/react/issues/24590) with the `use-sync-external-store` package (a React hook that Valtio uses to synchronize state changes with React) when using ES Modules.

There are a few things that need fixing:

Tell ESM.sh that it should not rewrite module imports for `use-sync-external-store`. ESM offers a parameter `external` to mark an import as external, so it won't be bundled. This means we need to define it ourselves:

```
"valtio": "https://esm.sh/valtio?external=use-sync-external-store/shim/index.js"
```

The annoying thing is that we need to point to the complete path of the imported module (including `shim/index.js`). Instead, we can mark all dependencies as external by prefixing the package with an asterisk:
```
"valtio": "https://esm.sh/*valtio
```

This means we need to define import mappings for all of valtio's dependencies:

```
"proxy-compare": "https://esm.sh/proxy-compare",
"use-sync-external-store/shim/index.js": "https://esm.sh/use-sync-external-store?alias=react:preact/compat"
```

### Issue with Preact standalone

For some reason the Preact standalone module breaks things due to a missing module. The solution was to import Preact and htm separately and create a module that initializes htm with Preact:

```js
import { h } from 'preact';
import htm from 'htm';

export default htm.bind(h);
```

Then use this to render components:

```js
import html from '../render.mjs';

export const Footer = () => html`<h1>Footer</h1>`;
```

## Run the app

Now we only need to some kind of static file server so we can preview the app in our browser. You can use the [VSCode Live Preview extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode.live-server) or use a [simple static server](https://www.npmjs.com/package/serve) like this: 

```
npx serve
```

# Conclusion

Creating an app without a bundler was a fun and overall a pretty smooth experience. ES6 has all the language features needed to create apps with a great developer experience. We've seen how dependencies can be imported from a CDN to add third party code to our app without the need for extra tools.

Still, I probably wouldn't go without a bundler for production apps in 2022. Choosing which tools to use is a trade-off between complexity of the build process and productivity + optimizations that you get by using these tools. Using third party libraries in a buildless app might prove hard if there is no ESM version available.

Pika is a great initiative that moves the complexity of build tools away from the app. It is a step towards a simpler development process. It's nice to see that the JS ecosystem is moving towards ES modules, which makes a lot of sense to me.

# Sources

- https://world.hey.com/dhh/modern-web-apps-without-javascript-bundling-or-transpiling-a20f2755
- https://blog.logrocket.com/building-without-bundling/
- https://dev.to/pika/a-future-without-webpack-ago
- https://dev.to/open-wc/developing-without-a-build-1-introduction-26ao
- https://dev.to/open-wc/on-the-bleeding-edge-3cb8