Writing a TodoMVC App With Modern Vanilla JavaScript

I took a shot at coding TodoMVC with modern (ES6+), vanilla JavaScript, and it only took ~170 lines of code and just over an hour! Compare this to the old/official TodoMVC vanilla JS solution, which has over 900 lines of code. An 80%+ reduction in code! I ❤️ the new state of JavaScript.

The code has received over 🤩 600 stars on GitHub:

In general, the responses were very positive. But as with all popular things, eventually, they spark debate.

React / Frameworks vs. Vanilla JS: Top Four Arguments for Frameworks

#1: “Frameworks Enable Declarative UI”

Modern frameworks like React and Vue don’t exist to fill in the gap left by native JS, they exist so that you write your application in a declarative way where the view is rendered as a function of state.

IMO this is simply a design pattern. Patterns apply in any language.

You can accomplish roughly the same thing in vanilla JavaScript. In my code, when the model changes, it fires a save event, and then I wire App.render() to it, which renders the App using the Todos model.

Todos.addEventListener('save', App.render);
Code language: JavaScript (javascript)

Template strings end up pretty easy to work with when you want to re-render parts of the App from scratch as a framework would:

`
  <div class="view">
    <input class="toggle" type="checkbox" ${todo.completed ? 'checked' : ''}>
    <label></label>
    <button class="destroy"></button>
  </div>
  <input class="edit">
`
Code language: HTML, XML (xml)

The entire App render method is only eleven lines, and it re-renders everything the App needs to based on the state of the App:

render() {
  const count = Todos.all().length;
  App.$.setActiveFilter(App.filter);
  App.$.list.replaceChildren(
    ...this.Todos.all(this.filter).map((todo) => this.renderTodo(todo))
  );
  App.$.showMain(count);
  App.$.showFooter(count);
  App.$.showClear(Todos.hasCompleted());
  App.$.toggleAll.checked = Todos.isAllCompleted();
  App.$.displayCount(Todos.all('active').length);
}
Code language: JavaScript (javascript)

Here I could have chosen to rebuild the entire UI as a template string as a function of state, but instead, it is ultimately more performant to create these DOM helper methods and modify what I want.

#2: “Frameworks Provide Input Sanitization”

The best way to sanitize user input is to use node.textContent.

insertHTML(li, `
  <div class="view">
    <input class="toggle" type="checkbox" ${todo.completed ? 'checked' : ''}>
    <label></label>
    <button class="destroy"></button>
  </div>
  <input class="edit">
`);
li.querySelector('label').textContent = todo.title;
Code language: HTML, XML (xml)

Any user input must be set to the DOM using textContent. If you do that, then you’re fine.

Beyond this, there is a new Trusted Types API for sanitizing generated HTML. I would use this new API if I were generating nested markup with dynamic, user-input data. (Note that this new API isn’t available yet in Safari, but hopefully, it will be soon)

Trusted Types not being everywhere is fine. You can use them where they’re supported and get early warning of issues. Security improves as browsers improve, and usage turns into an incentive for lagging engines (source)

Suppose you want a library to build your app template strings without using textContent manually. In that case, you can use a library like DOMPurify, which uses Trusted Types API under the hood.

#3: “Frameworks Provide DOM Diffing and DOM Diffing is Necessary”

The most common criticism was the lack of DOM Diffing in vanilla JavaScript.

A reactive UI/diff engine is non-negotiable for me.

Diffing is exactly what you need to do (barring newer methods like svelte) to figure out what to tell the browser to change. The vdom tree is much faster to manipulate than DOM nodes.

However, I think this is a much more balanced take:

Diffing seems necessary when your UI gets complicated to the level that a small change requires a full page re-render. However, I don’t think this is necessary for at least 95% of the websites on the internet.

I agree most websites and web apps don’t suffer from this issue, even when re-rendering the needed components based on vanilla’s application state like a framework.

Lastly, I’ll note that DOM diffing is inefficient for getting reactive updates because it doubles up data structures. Lit, Svelte, Stencil, Solid, and many others don’t need it and are way more performant as a result. These approaches win on performance and memory use, which matters because garbage collection hurts the UX.

Modern frameworks necessitate that you render the entire App client-side which makes your apps slow by default.

My issue with modern frameworks forcing declarative UI (see #1) and DOM diffing (see #2) approach is that they necessitate unnecessary rendering and slow startup times. Remix is trying to avoid this by rendering server-side then “hydrating,” and new approaches like Quik are trying not to have hydration altogether. It’s an industry-wide problem, and people are trying to address it.

In my vanilla JavaScript projects, I only re-render the most minimal parts of the page necessary. Template strings everywhere, and especially adding DOM diffing, is inefficient. It forces you to render all of your App client-side increasing startup time and the amount the client has to do overall each time data changes.

That said, if you do need DOM diffing in parts of a vanilla app, libraries like morphdom do just that. There is also a fantastic templating library called Lit-html that solves this problem of making your App more declarative in a tiny package (~3KB), and you can continue using template strings with that.

#4: “Frameworks Scale, Vanilla JavaScript Will Never Scale”

I have built many large vanilla JavaScript projects and scaled them across developers, making the companies I worked for tons of money, and these apps still exist today. 🕺✨

Conventions and idioms are always needed, no matter if you build on top of a framework or not.

At the end of the day, your codebase will only be only as good as your team, not the framework.

The way vanilla JS scales is the same way any framework scales. You have to have intelligent people talk about the needs of the codebase and project.

App Architecture Branch:

That said, here’s an example of adding ~20 lines of structure to the code in the app architecture branch. It splits the code into a TodoList and App component. Each component implements a render method that optionally renders a filtered view of the data.

Overall I’d argue these solutions are more performant, less code (~200 lines only), and more straightforward than most, if not all, the TodoMVC implementations on the internet without a framework.

Here are Eight Vanilla JavaScript Tips from the Code

#1. Sanitization

User input must be sanitized before being displayed in the HTML to prevent XSS (Cross-Site Scripting). Therefore new todo titles are added to the template string using textContent:

li.querySelector('label').textContent = todo.title;
Code language: JavaScript (javascript)

#2. Event Delegation

Since we render the todos frequently, it doesn’t make sense to bind event listeners and clean them up every time. Instead, we bind our events to the parent list that always exists in the DOM and infer which todo was clicked or edited by setting the data attribute of the item $li.dataset.id = todo.id;

Event delegation uses the matches selector:

export const delegate = (el, selector, event, handler) => {
  el.addEventListener(event, e => {
    if (e.target.matches(selector)) handler(e, el);
  });
}
Code language: JavaScript (javascript)

When something inside the list is clicked, we read that data attribute id from the inner list item and use it to grab the todo from the model:

delegate(App.$.list, selector, event, e => {
  let $el = e.target.closest('[data-id]');
  handler(Todos.get($el.dataset.id), $el, e);
});
Code language: PHP (php)

#3. insertAdjacentHTML

insertAdjacentHTML is much faster than innerHTML because it doesn’t have to destroy the DOM first before inserting.

export const insertHTML = (el, html) => {
  el.insertAdjacentHTML("afterbegin", html);
}
Code language: JavaScript (javascript)

Bonus tip: Jonathan Neal taught me through a PR that you can empty elements and replace the contents with el.replaceChildren() — thanks Jonathan!

#4. Grouping DOM Selectors & Methods

DOM selectors and modifications are scoped to the App.$.* namespace. In a way, it makes it self-documenting what our App could potentially modify in the document.

$: {
  input: document.querySelector('[data-todo="new"]'),
  toggleAll: document.querySelector('[data-todo="toggle-all"]'),
  clear: document.querySelector('[data-todo="clear-completed"]'),
  list: document.querySelector('[data-todo="list"]'),
  count: document.querySelector('[data-todo="count"]'),
  showMain(show) {
    document.querySelector('[data-todo="main"]').style.display = show ? 'block': 'none';
  },
  showFooter(show) {
    document.querySelector('[data-todo="main"]').style.display = show ? 'block': 'none';
  },
  showClear(show) {
    App.$.clear.style.display = show ? 'block': 'none';
  },
  setActiveFilter(filter) {
    document.querySelectorAll('[data-todo="filters"] a').forEach(el => el.classList.remove('selected')),
    document.querySelector(`[data-todo="filters"] [href="#/${filter}"]`).classList.add('selected');
  },
  displayCount(count) {
    replaceHTML(App.$.count, `
      <strong>${count}</strong>
      ${count === 1 ? 'item' : 'items'} left
    `);
  }
},
Code language: JavaScript (javascript)

#5. Send Events on a Class Instance with Subclassing EventTarget

We can subclass EventTarget to send out events on a class instance for our App to bind to:

export const TodoStore = class extends EventTarget {
Code language: JavaScript (javascript)

In this case, when the store updates, it sends an event:

this.dispatchEvent(new CustomEvent('save'));
Code language: JavaScript (javascript)

The App listens to that event and re-renders itself based on the new store data:

Todos.addEventListener('save', App.render);
Code language: JavaScript (javascript)

#6. Group Setting Up Event Listeners

It is essential to know exactly where the global event listeners are set. An excellent place to do that is in the App init method:

init() {
  Todos.addEventListener('save', App.render);
  App.filter = getURLHash();
  window.addEventListener('hashchange', () => {
    App.filter = getURLHash();
    App.render();
  });
  App.$.input.addEventListener('keyup', e => {
    if (e.key === 'Enter' && e.target.value.length) {
      Todos.add({ title: e.target.value, completed: false, id: "id_" + Date.now() })
      App.$.input.value = '';
    }
  });
  App.$.toggleAll.addEventListener('click', e => {
    Todos.toggleAll();
  });
  App.$.clear.addEventListener('click', e => {
    Todos.clearCompleted();
  });
  App.bindTodoEvents();
  App.render();
},
Code language: JavaScript (javascript)

Here we set up all the global event listeners, subscribe to the store mentioned above, and then initially render the App.

Similarly, when you create new DOM elements and insert them into the page, group the event listeners associated with the new elements near where they are made.

#7. Use Data Attributes in Markup & Selectors

One issue with JavaScript is your selectors get tightly coupled to the generated DOM.

To fix this, classes should be used for CSS rules, and data atributes for JavaScript behavior.

<div data-jsmodule="behavior"></div>
Code language: HTML, XML (xml)
document.querySelector('[data-jsmodule="behavior"]')
Code language: JavaScript (javascript)

#8. Render the State of the World Based on Data (Data Flowing Down)

Lastly, to reiterate what I said above, render everything based on the state in the render() method. This is a pattern lifted from modern frameworks.

Make sure you update the DOM based on your App state, not the other way around.

It’s even better if you avoid reading DOM to derive any part of your app state aside from finding your target for event delegation.

Side note: I like to rely on the server to generate the markup for faster boot times, then take control of the bits we show. Have the CSS initially hide things you don’t need, and then have the JavaScript show the elements based on the state. Let the server do most of the work where you can, rather than wait for the entire App to render client-side.

In Conclusion

Vanilla JS is Viable Today for Building Web Apps

JavaScript is better today than it has ever been.

The fact that I could shave off 80% of the code over the previous TodoMVC years ago at the drop of a hat feels terrific. Plus, we now have established design patterns that we can lift from modern frameworks to apply to vanilla JavaScript projects to make our UIs as declarative as we like.

As an industry we should consider pure JavaScript as an option for more projects.

Finally, as Web Components get more ergonomic, we will even have a way to share our code in an interoperable and framework-agnostic way.

I hope you enjoyed the post. Please send your feedback to me @1marc on Twitter. Cheers!


Bonus: Performant Rendering of Large Lists

The code for rendering the entire list contents on model change is clean because data flows down, but it potentially will not be as performant for rendering large lists.

More Performant & Granular DOM Updates with Vanilla

Here’s a branch sending specific events with context from the model so we can make DOM updates more selectively as we need them: (see granular DOM updates diff).

performant-rendering branch

More Performant DOM Updates with lit-html (plus animations!)

We can acheieve the same performant DOM updates with far less code by adopting lit-html using the repeat directive: (see adding lit-html diff).

animation-lithtml branch

Leave a Reply

Your email address will not be published. Required fields are marked *