Skip to main content Accessibility Feedback

Building complex apps with vanilla JavaScript (a series)

When I promote vanilla JS, one of the places I often get pushback is around building complex UIs.

Sure, vanilla JS is great for simple scripts and web apps. But as soon as things get complex, you need React.

Over the next few days, I want to take explore the edges where vanilla JS starts to fall apart, and highlight the vibrant middle ground between “100% hand-rolled vanilla JS” and “just use React.”

Let’s dig in!

A todo app

For this series, we’re going to look at the most cliche example of all examples: a todo app.

It’s a cliche example in large part because it seems deceiving simple, and can be built with varying levels of complexity. It’s a great teaching project!

For our purposes, let’s imagine we have a form element and an unordered list (ul).

<form id="add-todo">
	<label>What do you want to do?</label>
	<input type="text" id="todo">
	<button>Add Todo</button>
</form>

<ul id="app"></ul>

Whenever the user submits a todo using the #add-todo form, we want to create a new list item (li) and add it to the #app list.

Using traditional DOM manipulation

The old-school vanilla JS version of this app takes 28 lines of code, including comments and white space.

First, let’s get the app and form elements using the document.querySelector() method, and assign them to variables.

// DOM elements
let app = document.querySelector('#app');
let form = document.querySelector('#add-todo');

Next, lets listen for submit events on our form, and addTodo items whenever that happens.

// Add todos when form is submited
form.addEventListener('submit', addTodo);

Inside the addTodo() function, we’ll first use the event.preventDefault() method to stop the form from reloading the page.

Then, we’ll make sure the #todo field inside the form has a value. If not, we’ll end our function early.

Because the field has an ID, we can access it directly as a property of the form element.

/**
 * Add todo to the list
 * @param {Event} event The event object
 */
function addTodo (event) {

	// Stop the form from reloading the page
	event.preventDefault();

	// If there's no field value, ignore the submission
	if (!form.todo.value) return;

}

Otherwise, we’ll use the document.createElement() method to create a new list item (li), and set the form.todo.value as its textContent.

Then, we can use the Element.append() method to inject it into the app list. We’ll also clear the form field.

/**
 * Add todo to the list
 * @param {Event} event The event object
 */
function addTodo (event) {

	// Stop the form from reloading the page
	event.preventDefault();

	// If there's no field value, ignore the submission
	if (!form.todo.value) return;

	// Otherwise, add a todo
	let li = document.createElement('li');
	li.textContent = form.todo.value;
	app.append(li);

	// Clear the form field
	form.todo.value = '';

}

Now, we’ve got a basic todo app stood up with just a few lines of code. Try it yourself on CodePen.

Adding features (and a bit of complexity)

Now we’ve got an app where we can add todo items. But, every time the user reloads the page, they get wiped out.

We can fix that pretty easily with localStorage.

Whenever we add a todo item, we’ll use the localStorage.setItem() method to save the innerHTML of the app element.

// Clear the form field
form.todo.value = '';

// Save list
localStorage.setItem('todos', app.innerHTML);

And whenever the page is opened, we’ll look for saved todos and load them into the UI.

// Add todos when form is submited
form.addEventListener('submit', addTodo);

// Load saved todos
app.innerHTML = localStorage.getItem('todos') || '';

But if you’re going to do this, you also need to provide a way for users to remove todo items.

After creating our list item, let’s create a button with the [data-delete] attribute on it, and append() it inside the li element.

// Otherwise, create a todo
let li = document.createElement('li');
li.textContent = form.todo.value;

// Add a remove button
let btn = document.createElement('button');
btn.textContent = 'Delete';
btn.setAttribute('data-delete', '');
li.append(btn);

// Append to the UI
app.append(li);

Since we’re dynamically adding buttons, we can listen for clicks on our button using event delegation. We’ll listen for all clicks in the document, and run the removeTodo() function in response.

// Remove todos when delete button is clicked
document.addEventListener('click', removeTodo);

Inside the removeTodo() function, we’ll ignore any clicks that weren’t triggered by a [data-delete] button.

We can check that by running the Element.matches() method on the event.target, the element that triggered the event.

/**
 * Remove todo items
 * @param  {Event} event The event object
 */
function removeTodo (event) {

	// Only run on [data-delete] items
	if (!event.target.matches('[data-delete]')) return;

}

If it was a delete button, we’ll use the Element.closest() method to find the parent list item (li), and the Element.remove() method to remove it from the DOM.

Then, we’ll run the localStorage.setItem() method again to update our saved list.

/**
 * Remove todo items
 * @param  {Event} event The event object
 */
function removeTodo (event) {

	// Only run on [data-delete] items
	if (!event.target.matches('[data-delete]')) return;

	// Otherwise, remove the todo
	let li = event.target.closest('li');
	if (!li) return;
	li.remove();

	// Save the list
	localStorage.setItem('todos', app.innerHTML);

}

Here’s another demo.

Things get unmanageably complex pretty fast

At this point, we’re still in the world of “not too bad” with traditional DOM manipulation (in my opinion).

But that changes pretty fast!

For example, we probably want to display a message when there are no todo items yet. With traditional DOM manipulation, the easiest way to do that is to add an element in the DOM that we selectively show and hide.

<p id="no-todos" hidden><em>You don't have any todos yet.</em></p>

First, we’ll create another variable for the noTodos element.

// DOM elements
let app = document.querySelector('#app');
let form = document.querySelector('#add-todo');
let noTodos = document.querySelector('#no-todos');

Then, we’ll create a loadSavedTodos() function to help us out.

If there’s saved todos, we’ll show them. Otherwise, we’ll use the Element.removeAttribute() method to remove the [hidden] attribute and show our message.

/**
 * Load saved todo items into the UI
 */
function loadSavedTodos () {
	let saved = localStorage.getItem('todos');
	if (saved) {
		app.innerHTML = saved;
	} else {
		noTodos.removeAttribute('hidden');
	}
}

Then, we’ll run this method instead of directly loading saved todos.

// Load saved todos
loadSavedTodos();

Whenever we append a list item to the app, we’ll add the [hidden] attribute attribute back to hide the message if it’s current visible.

// Append to the UI
app.append(li);

// Hide the no-todos message
noTodos.setAttribute('hidden', '');

And when we remove a todo item, if there are no list items in our app, we’ll remove the attribute again to show the message.

// Save the list
localStorage.setItem('todos', app.innerHTML);

// If there are no todos, show the no-todos message
if (!app.innerHTML.trim().length) {
	noTodos.removeAttribute('hidden');
}

Here’s another demo.

We’ve reached the breaking point

We’re now up to 85 lines of code (with comments and whitespace), and there’s still a ton of features you’d probably want to add to an app like this.

For example, you probably want a button to clear all todos. But you only want to show it if there are todo items to remove.

And you might also want to provide users with a way to edit existing todo items or mark them as complete.

At this point, the cost of adding new features has gotten quite high. It requires keeping track of the current state of the UI, and selectively adding, removing, and updating various pieces.

And this is where a completely different approach to building the app makes sense.

Tomorrow, we’re going to dive into what that is.