DEV Community

Edwin
Edwin

Posted on • Edited on

Building a TODO app without a bundler

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 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).

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 and the end result:

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: 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, which is fully client side and has a clear scope.

  • Implement the TodoMVC app using this specification.
  • Use only native ES6 browser features.
  • 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, not primarily for the web. NPM packages are expected to use the CommonJS format to ensure everything is compatible with each other. Publishing a package using pure ES Modules 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 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:

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 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.
  • Managing module imports can become messy, resulting in long relative import paths ../../../module/subModule/component.mjs. Webpack has aliases to make your life easier. Luckily, JS 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, unpkg and SkyPack. 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:

<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

ES modules allow you to import directly from a URL:

import groupBy from 'https://unpkg.com/lodash-es@3.10.1/collection/groupBy.js';
Enter fullscreen mode Exit fullscreen mode

Learn more about ES imports in this article

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 which builds on top of the web components standard. (example app)
  • Vue Single File Component loader allows you to sprinkle Vue on top of any HTML document. (example app)
  • HTM - a library that lets you write components using JSX-like syntax using template string.
  • Symbiote - framework that allows you to write class based Custom Elements, focused on building complex widgets that you can then embed in other apps.

Here 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 (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:

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

const myH1Element = h('h1', null, 'Hello World!'); // <h1>Hello World!</h1>
Enter fullscreen mode Exit fullscreen mode

HTM uses JavaScript's own tagged template syntax, so you can write JSX-like syntax:

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);
Enter fullscreen mode Exit fullscreen mode

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

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

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

State management using Valtio

Valtio 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 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 with Preact. 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.

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:

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

    <body>
        <script type="module" src="js/index.mjs"></script>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode

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

<link rel="stylesheet" href="https://unpkg.com/todomvc-common@1.0.5/base.css" />
Enter fullscreen mode Exit fullscreen mode

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/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);
Enter fullscreen mode Exit fullscreen mode

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:

import { html, render } from 'preact';
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

In this case we are using 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 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. We can do this by using the alias URL parameter:

https://esm.sh/valtio?alias=react:preact/compat
Enter fullscreen mode Exit fullscreen mode

Issue with use-sync-external-hook

There is an issue 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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:

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

export default htm.bind(h);
Enter fullscreen mode Exit fullscreen mode

Then use this to render components:

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

export const Footer = () => html`<h1>Footer</h1>`;
Enter fullscreen mode Exit fullscreen mode

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 or use a simple static server like this:

npx serve
Enter fullscreen mode Exit fullscreen mode

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

Top comments (4)

Collapse
 
artydev profile image
artydev

Thanks,
Look at this implementation DMLTodo

Collapse
 
igpineda profile image
igpineda

Great job! Did you utilize VSCode? Also, what tool did you use for formatting your HTM code within your MJS files? I've been working with a similar setup, and code formatting has been a nightmare for me.

Collapse
 
ekeijl profile image
Edwin

Hey, thanks! I think I used VSCode for this product but it should not matter all too much, I also use WebStorm a lot for other projects. Most IDEs have automatic formatting features. Prettier is the industry standard, here's a plugin for VSCode.

Or you could run Prettier directly from your command line.

This formats all files in the current directory (and subdirectories):

npx prettier . --write
Enter fullscreen mode Exit fullscreen mode
Collapse
 
morovinger profile image
Jura Shekhanov

awesome stuff man, thanks a bunch