Vanilla JavaScript? Consider using a library instead: a practical example

Vanilla JavaScript? Consider using a library instead: a practical example

The answer to this question must come in the form of a practical example that, in my humble opinion, speaks louder and clearer than thousand words. In this article we're going to create the (in)famous Todo App without using any JavaScript or CSS libraries.

The answer to this question must come in the form of a practical example that, in my humble opinion, speaks louder and clearer than thousand words. In this article we're going to create the (in)famous Todo App without using any JavaScript or CSS libraries.

First, let's start with the specifications. Our app must:

  1. provide a form for creating new entries
  2. provide a way to filter entries
  3. provide a way to sort entries both in ascending and descending order based on the creation date of each entry
  4. provide a way to remove entries
  5. data must be persistent

Each entry must have:

  1. a description field
  2. a date and time field in the current locale
  3. a unique ID field

Bear in mind that we cannot use Angular, React, Vue, Svelte or anything else that barely resembles a JavaScript library. Even a simple tool for managing dates is not allowed. Coming to CSS, frameworks like Bootstrap or Tailwind are not allowed. We must therefore roll up our sleeves and work with the DOM and plain CSS. We won't cover the CSS implementation because our primary focus is on JavaScript only. Let's get started.

We cannot use modules or components, so the HTML and DOM structure of our app will be contained within a single HTML page.

<form action="" method="post" class="todo-form">
        <div>
            <input type="text" class="todo-description">
            <button type="submit">Add</button>
        </div>
    </form>
    <div class="todo-actions hidden">
        <div class="todo-sort">
            <ul>
                <li data-order="ASC" class="active">ASC</li>
                <li data-order="DESC">DESC</li>
            </ul>
        </div> 
        <form class="todo-search" method="get" action="">
            <div>
                <input type="text" class="todo-search-term" placeholder="Search...">
                <button type="button" class="todo-reset-search">&times;</button>
                <input type="submit" class="hidden" value="Find">
            </div>
            
        </form> 
    </div> 
    
    <ul class="todo-list"></ul>
    <script src="app.js"></script>

One requisite tells us that data must be persistent. This means that we cannot simply manipulate an array of objects but we must also use Web Storage in order to make our entries available after page reload. Our JavaScript code structure in the app.js file will be as follows:

'use strict';

(function() {
    // Implementation here
    
    document.addEventListener('DOMContentLoaded', () => {
         // References to the main DOM elements
         const todoList = document.querySelector('.todo-list');
        const todoForm = document.querySelector('.todo-form');
        const todoInput = todoForm.querySelector('.todo-description');
        const todoSort = document.querySelector('.todo-sort');
        const todoSearch = document.querySelector('.todo-search');
        const todoResetSearchBtn = document.querySelector('.todo-reset-search');
        
        // Initialization here
    }, false);
})();

The first noticeable difference when working without a library is that we have to manually reference every single DOM element we want to work with. Here we had to write six variables to create those references. A thing to keep in mind is that if one of these elements doesn't exist in the DOM tree, you will actually get a reference to null. This problem can be actually mitigated if you use TypeScript, but this is something that we need to addresss every time we want to manipulate the DOM.

In order to have a clear idea of how an entry should look like, we can create a class and treat it as a model.

 class Todo {
        constructor(id, description, date) {
            this.id = id;
            this.description = description;
            this.date = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
        }
    }

The date and description fields are plain text strings, but the id field should contain a string that must be a unique string. Since we cannot use library to generate unique strings that will avoid collisions (such as UUIDs), we have to fall back to a quick and dirty solution:

 const createTodoID = () => 'todo-' + Math.random().toString().substring(2);

Here we rely on the format that a random number between 0 and 1 takes when converted to string, namely 0. followed by a variable length series of digits. This solution is far from being fully acceptable but it works and since we can't use an external library and we can't spend too much time in this preliminary phase, we have to stick to it for the moment.

Now we can start with the actual implementation. First, let's define the relevant array for our entries:

let todos = [];

Our todos must be persistent, so we need to use the localStorage object by saving the array as a JSON string and then convert it back to a JavaScript array. This routine must be executed at the very beginning of our initialization phase.

const initStorage = () => {
        if(localStorage.getItem('todos') === null) {
            localStorage.setItem('todos', JSON.stringify(todos));
        } else {
            todos = JSON.parse(localStorage.getItem('todos'));
        }    
    };

Then we can invoke it as follows:

// Initialization here

initStorage();

Before moving on, we need a generic utility function that inserts a list of todo items into the corresponding todo list:

const displayTodos = (items = [], target = null) => {
        if(items.length === 0 || target === null) {
            return false;
        }
        let content = '';
        for(const todo of items) {
            content += `<li class="todo" id="${todo.id}">${todo.description} ${todo.date} <button type="button" class="todo-remove">&times;</button></li>`;
        }
        target.innerHTML = content;
    };

Then we also need a function that turns the todo array into a list of items that will create the HTML content of the relevant list.

const getTodos = (target = null) => {
        if(target === null || todos.length === 0) {
            return false;
        }
        displayTodos(todos, target);
        handleTodoActions(document.querySelector('.todo-actions'));
    };

The sorting and search components are initially hidden, because if there are no todos there's no point in showing them. To handle this scenario, we need to create a specialized function that removes the hidden CSS class if there are more than 1 todo in the array.

const handleTodoActions = (target = null) => {
        if(target === null) {
            return false;
        }
        if(todos.length > 1) {
            target.classList.remove('hidden');
        } else {
            target.classList.add('hidden');
        }
    };

We also need a utility function to save our array in the web storage:

const saveStorage = () => {
        localStorage.setItem('todos', JSON.stringify(todos));
    };

The creation of a brand new todo item comes in two different but connected steps: first, we need to add the new item to the todos array and save it in the web storage, then we have to create a new list item and add it to the DOM structure of the todo list.

const addTodo = todo => {
        if(!todo || !todo instanceof Todo) {
            return false;
        }
        todos.push(todo);
        saveStorage();
        handleTodoActions(document.querySelector('.todo-actions'));
    };
    
const createTodo = (target = null, content = '', input = null) => {
        if(target === null) {
            return false;
        }
        const todo = document.createElement('li');
        
        const id = createTodoID();
        const todoObj = new Todo(id, content, new Date());

        todo.id = id;
        todo.className = 'todo';
        todo.innerText = content + ' ' + todoObj.date;
        const remove = document.createElement('button');
        remove.type = 'button';
        remove.className = 'todo-remove';
        remove.innerHTML = '&times;';
        todo.appendChild(remove);
        target.appendChild(todo);
        addTodo(todoObj);

        if(input !== null) {
            input.value = '';
        }

        return true;
    };    

The creation of a new item has been performed by using a standard DOM procedure instead of simply adding an HTML string because part of the todo description is directly inserted by the user, thus potentially leading to XSS attacks. Instead, we used the innerText property because we can't use an external library to sanitize the user's input (again!).

At this point we have to bind these routines to the main HTML form:

const handleSubmit = (form = null) => {
        if(form === null) {
            return false;
        }
        form.addEventListener('submit', e => {
            e.preventDefault();
            const input = form.querySelector('.todo-description');
            const value = input.value;
            if(value && !/^\s+$/.test(value)) {
                return createTodo(document.querySelector('.todo-list'), value, input);
            }
        }, false);
    };

Since we cannot use an external library to validate form fields and their values, we are forced to manually check if the given string value has a length greater than zero and is not made up of spaces only.

We have implemented the creation of a new todo so far and now it's time to handle the deletion. To do so, first we remove a specific todo from the array and save the resulting array into the web storage, then we need to attach an event handler to each button with class todo-remove in the todo list.

const removeTodo = id => {
        if(todos.length === 0) {
            return false;
        }
        todos = todos.filter(td => td.id !== id);
        saveStorage();
        handleTodoActions(document.querySelector('.todo-actions'));
    };
const handleTodoRemove = (target = null, input = null) => {
        if(target === null) {
            return false;
        }
        target.addEventListener('click', e => {
            const element = e.target;
            if(element.matches('.todo-remove')) {
                element.parentNode.remove();
                removeTodo(element.parentNode.id);

                if(input !== null) {
                    input.value = '';
                }
            }
        }, false);
    };    

The target parameter used here is a reference to the todo list itself because buttons are inserted dynamically and we have to implement a simple form of event delegation in order to remove an item from the DOM. event.target points to the current element being clicked within the list; if the CSS selector is (matches()) .todo-remove, then we remove its parent node and we use the ID attribute of the parent node to remove the current todo item from the todos array as well.

Now, how we can implement sorting? Simply enough, we need to convert each date field of our todos into Unix timestamps and simply sort the resulting array in descending or ascending order based on which element has been chosen by the user with the .todo-sort block using the data attribute of each list item.

const sortTodos = (order = 'ASC') => {
        const list = todos.map(todo => {
            let dateTime = todo.date;
            let dateParts = dateTime.split(' ');
            let dt = dateParts[0].split('/').reverse().join('-');
            let ts = new Date(dt + ' ' + dateParts[1]).getTime();
            return {...todo, ts};
        }).sort((a, b) => {
            if(order === 'DESC') {
                return -1;
            }
            if(order === 'ASC') {
                return 1;
            }
            return 0;
        });
        displayTodos(list, document.querySelector('.todo-list'));
    };
    
const toggleSorting = (target = null) => {
        if(target === null) {
            return false;
        }
        target.addEventListener('click', () => {
            const active = target.querySelector('.active');
            const sibling = active.nextElementSibling ? active.nextElementSibling : active.previousElementSibling;
            if(sibling) {
                sibling.className = 'active';
                active.className = '';
            }
            sortTodos(target.querySelector('.active').dataset.order);
        }, false);
    };    

The initial todos array is transformed by the map() method by adding to each item the ts property that holds a Unix timestamp created from the original date string. Later on, the array is passed to the sort() method that performs the actual sorting routine.

Now it's time to implement the search/filter functionality. To do so, we need to find a match in the description field of each item by performing a case-insensitive text search.

const searchTodos = (term = '') => {
        initStorage();
        const results = todos.filter(todo => {
            return todo.description.toLowerCase().includes(term.toLowerCase());
        });
        if(results.length > 0) {
            displayTodos(results, document.querySelector('.todo-list'));    
        }
    };

    const handleSearch = (form = null) => {
        if(form === null) {
            return false;
        }
        form.addEventListener('submit', e => {
            e.preventDefault();
            const term = form.querySelector('.todo-search-term').value;
            searchTodos(term);
        }, false);
    };

The filter() method will return an array containing only the items whose description has the search term in it as a substring. If there are no results, we won't proceed any further because users will just see an empty list and this will lead to a really bad user experience.

Finally, we can implement the complete reset of the search results:

const handleResetSearch = (btn = null, target = null) => {
        if(btn === null || target === null) {
            return false;
        }
        btn.addEventListener('click', () => {
            getTodos(target);
            btn.previousElementSibling.value = '';
        }, false);
    };

Conclusion: it works, but...

  1. it's redundant
  2. it's not scalable
  3. it's not modular
  4. it's hardly maintainable
  5. it's hardly testable
  6. it does not adhere to a common coding standard/design
  7. it reinvents the wheel several times
  8. it's unreliable (e.g. unique IDs creation)

That's why you should consider using a library.

Demo

Todo App