Next Level Web Development: Creating a Single Page Application Without a Framework

In this post, I want to show you how to create a single-page application without using a framework.

project

This is the application we will implement: a to-do list where we can type the name of a new to-do item, and it will be added to our list. Additionally, we can toggle the to-do item to mark it as completed. We can also filter our to-do items and see the number of items in the footer that need to be completed.

Furthermore, we can remove or edit to-do items. With a double-click, we enter editing mode, where we can change the item's name. The goal of this post is to implement such a project without using any frameworks.

Initial Project

I generated an empty JavaScript project using Vite. In our project, I have already prepared the basic markup.

<section class="todoapp">
  <header class="header">
    <h1>todos</h1>
    <input
      class="new-todo"
      placeholder="What needs to be done?"
      autofocus
    />
  </header>
  <section class="main">
    <input id="toggle-all" class="toggle-all" type="checkbox" />
    <label for="toggle-all">Mark all as complete</label>
    <ul class="todo-list"></ul>
  </section>
  <footer class="footer">
    <span class="todo-count"><strong>0</strong> items left</span>
    <ul class="filters">
      <li filter="all">
        <a class="selected">All</a>
      </li>
      <li filter="active">
        <a>Active</a>
      </li>
      <li filter="completed">
        <a>Completed</a>
      </li>
    </ul>
  </footer>
</section>
<script type="module" src="/main.js"></script>

Here we have our header, an input field to create to-dos, an empty list for the to-dos, and a footer with filters. Additionally, I have included two CSS files for styling, which you can find at the end of this article in the source code.

Adding Structure

The first thing I want to do is create a new folder named todomvc. This is where all our JavaScript files for this project will be located. Inside this folder, we need two files: index.js and helpers.js.

In index.js, we will write all our main logic. In helpers.js, we will write all the helper functions that will work with our to-dos.

// main.js
import './todomvc'

To make it work, let's import our index.js file inside main.js.

The next question is how to structure our project. There are different ways to structure our application, but I want to write it using plain functions. Just to remind you, all functions that you create in plain JavaScript will go directly inside window and pollute the global namespace.

const someFn = () => {}

In plain JavaScript, this function would be global. However, since we generated our project with Vite, every single file in our project is a module, and thus this someFn will never get into the global namespace. All our functions are isolated, so we can safely create all functions directly inside index.js, and they are already isolated by default.

The first function I want to create is one that will initialize the whole project.

// todomvc/index.js
const initialize = () => {
}

All logic that must happen on initialization will be placed inside this function.

Next, we want to find all the DOM elements on the page. We have already rendered them in HTML, but now we need to access them in JavaScript. It doesn't make sense to find them every single time, so we can find all these elements at the beginning and then reuse them across the entire application.

let selectors = {};

const initialize = () => {
  findElements();
};

const findElements = () => {
  selectors = {
    newTodo: document.querySelector(".new-todo"),
    todoList: document.querySelector(".todo-list"),
    footer: document.querySelector(".footer"),
    main: document.querySelector(".main"),
    toggleAll: document.querySelector(".toggle-all"),
    count: document.querySelector(".todo-count"),
    filters: document.querySelector(".filters"),
  };
};

initialize()

We created an additional function findElements where we find all DOM nodes and store them inside a selectors object. As you can see, we created selectors before our functions so we can access them everywhere.

dom nodes

Here, we have references to all DOM nodes in our application within a single object.

Render Function

The next thing we need to do is create a render function. The idea is that this function will re-render the whole application based on our state. When we change our list of to-dos or a filter, this render function will simply remove the entire application and render it again with the new data, without needing to figure out which part to re-render. This approach significantly simplifies the rendering process.

let selectors = {};
let todos = [];
let filter = "all";

const initialize = () => {
  ...
  render();
};

const render = () => {
  selectors.todoList.innerHTML = "";
  selectors.main.style.display = todos.length > 0 ? "block" : "none";
  selectors.footer.style.display = todos.length > 0 ? "block" : "none";
};

initialize();

Here, we create two more variables for our state: an array todos and a filter set to all. We also created a render function and called it inside initialize. In our render function, we remove the entire content inside our list of to-dos and change the display style of main and footer. If we don't have any todos, then we hide both of them.

It's important to base our render function on the state. We don't care about what actions were taken; the render function simply renders the application with the correct state.

initial render

As you can see, we don't render the main and footer sections as we don't have any to-dos yet.

Attaching Listeners

The next thing we want to do is attach some listeners to our DOM nodes. When we type something in the input and hit Enter, we must create a new to-do item.

const initialize = () => {
  findElements();
  addListeners();
  render();
};

const addListeners = () => {
  selectors.newTodo.addEventListener("keyup", (event) => {
    if (event.key === "Enter") {
      console.log('submit', event.target.value)
    }
  });
};

Here, we called an addListeners function in our initialize process. Inside it, we added a keyup listener to our input field. If we hit Enter, we log the value in our input.

submit

After typing something and hitting Enter, we see our log. So, we successfully bound our keyup event to the input field.

Creating new Todo

Now we need to implement the creation of the new to-do.

const addTodo = (text) => {
  const newTodo = {
    id: crypto.randomUUID(),
    text,
    isCompleted: false
  }

  todos = [...todos, newTodo]
}

Here is our new addTodo function where we provide text, and it adds a new to-do to the array of to-dos. It is important to mention that I didn't use the push function here to update an array but used the spread operator and returned a new array.

Using immutable operators helps make your code predictable.

const addListeners = () => {
  selectors.newTodo.addEventListener("keyup", (event) => {
    if (event.key === "Enter") {
      addTodo(event.target.value)
      console.log(todos)
    }
  });
};

todos array

As you can see, our addTodo function was called and it updated our todos array by adding a new object inside.

However, here is a problem. Our index.js will become extremely large and difficult to maintain if we write all our business logic inside it. This is why I want to move all logic that implements working with state inside helpers. For example, this addTodo function doesn't belong here because it changes our state.

// todomvc/helpers.js
export const addTodo = (todos, text) => {
  const newTodo = {
    id: crypto.randomUUID(),
    text,
    isCompleted: false,
  };
  return [...todos, newTodo];
};

I copied a function and made a significant change to it. Now, it doesn't only receive text as a parameter but also a list of to-dos. This makes the function stateless; it doesn't know anything about the state. It simply updates the provided list by returning a new array.

However, we still needed an addTodo function in our index.js.

// todomvc/index.js
import * as helpers from "./helpers";
...
const addTodo = (text) => {
  todos = helpers.addTodo(todos, text);
  render();
};

Instead of writing business logic inside, we just call our helper function, which delivers the updated array, and then we call render afterwards.

The render function doesn't care what we did. It will just re-render the whole application based on the new array of to-dos.

Our next step is to write some logic about what render must do with our list of to-dos. As of now, it doesn't do anything.

// todomvc/helpers.js
const createTodoNode = (todo) => {
  const node = document.createElement("li");
  if (todo.isCompleted) {
    node.classList.add("completed");
  }
  node.innerHTML = `
    <div class="view">
      <input class="toggle" type="checkbox" ${
        todo.isCompleted ? "checked" : ""
      }>
      <label>${todo.text}</label>
      <button class="destroy"></button>
    </div>
    <input class="edit" value=${todo.text}>
  `;

  return node;
};

const render = () => {
  selectors.todoList.innerHTML = "";
  todos.forEach((todo) => {
    const todoNode = createTodoNode(todo);
    selectors.todoList.appendChild(todoNode);
  });
  ...
}

Here, we created createTodoNode, which generates a piece of markup for the to-do item. Again, this function is fully stateless; it just generates markup based on the provided to-do information. Inside, we render all the necessary markup for a single to-do.

In our render function, we remove the content of our to-do list and loop through the list of our to-dos, appending markup for each element.

created node

As you can see, now after adding an element, it was rendered on the screen. We just need a small improvement to clear the input after we hit Enter.

selectors.newTodo.addEventListener("keyup", (event) => {
  if (event.key === "Enter") {
    addTodo(event.target.value);
    selectors.newTodo.value = "";
  }
});

We simply set the value of the input to an empty string.

Items counter

The next thing I want to implement is a to-do counter in the footer. Currently, we always render "0 items left" because our render function doesn't update this information in the footer.

const render = () => {
  ...
  const activeTodosCount = todos.filter((todo) => !todo.isCompleted).length;
  selectors.count.innerHTML = `
    <strong>${activeTodosCount}</strong> ${
    activeTodosCount === 1 ? "item" : "items"
  } left
  `;
};

Every time we render our application, we calculate the number of active to-dos and render the correct information in the footer block.

counter

Now, after adding 2 new to-dos, our counter changed correctly.

Filters

The next feature that we need is filters in the footer, as well as the ability to change a filter. First, let's attach a click event to each filter.

const addListeners = () => {
  ...
  selectors.filters.querySelectorAll("li").forEach((li) => {
    li.addEventListener("click", () => {
      filter = li.getAttribute("filter");
      render();
      console.log(filter)
    });
  });
};

As filters is a parent element, we get all li elements as an array and attach a click event to each element. In order to determine which filter is clicked, we read the name of the clicked filter from its attribute. After this, we call render again.

In the console, you can see that by clicking on the filter, the value is changed, but nothing happens on the screen because our render function doesn't have filter logic yet.

const render = () => {
  ...
  selectors.filters.querySelectorAll("a").forEach((a) => {
    a.classList.remove("selected");
  });
  selectors.filters
    .querySelector(`[filter=${filter}] a`)
    .classList.add("selected");
};

Inside our render function, we looped through all filters and removed the selected class. After this, we find the element by its filter value and set it as selected.

filters

As you can see, we can now switch between filters.

Filtering todos

But here is the problem: our to-dos are not being filtered when we switch between filters. We always render all the to-dos that we have. This is incorrect, and we must filter the to-dos based on the activated filter.

const getFilteredTodos = () => {
  if (filter === "active") {
    return todos.filter((todo) => !todo.isCompleted);
  } else if (filter === "completed") {
    return todos.filter((todo) => todo.isCompleted);
  } else {
    return todos;
  }
};

const render = () => {
  ...
  getFilteredTodos().forEach((todo) => {
    const todoNode = createTodoNode(todo);
    selectors.todoList.appendChild(todoNode);
  });
  ...
};

Here is a new function, getFilteredTodos, which returns only the to-dos based on the activated filter. Now, instead of looping through all to-dos in the render function, we use getFilteredTodos to render only the to-dos that are needed.

filtering todos

Here, we switch to the completed tab, and we don't see any to-dos as they are filtered out.

Remove todo

The next feature that we want to implement is the removal of to-dos. However, we haven't created a handler for this button yet.

const createTodoNode = (todo) => {
  ...
  node
    .querySelector(".destroy")
    .addEventListener("click", () => removeTodo(todo.id));

  return node;
}

When we create a to-do, we want to directly attach a click event to the remove button and call a removeTodo function.

const removeTodo = (todoId) => {
  todos = helpers.removeTodo(todos, todoId);
  render();
};

Our removeTodo function will use a helper which will update a list of to-dos and then call render afterwards. So, it is similar to addTodo. Let's implement a helper function for this now.

// todomvc/helpers.js
export const removeTodo = (todos, todoId) => {
  return todos.filter((todo) => todo.id !== todoId);
};

It's a small function that filters our list of to-dos by removing the to-do with the specific ID.

delete todo

As you can see, after clicking on the remove button, it re-renders our project with an empty list.

Toggle todo

Another feature that we need is toggling our to-dos. We must implement it similarly to removeTodo.

const createTodoNode = (todo) => {
  ...
  node
    .querySelector(".toggle")
    .addEventListener("click", () => toggleTodo(todo.id));
}

We attached a click event to the toggle button of the to-do.

const toggleTodo = (todoId) => {
  todos = helpers.toggleTodo(todos, todoId);
  render();
};

In the toggleTodo function, we want to call a helper and then render afterwards.

// todomvc/helpers.js
export const toggleTodo = (todos, todoId) => {
  return todos.map((todo) => {
    if (todo.id === todoId) {
      return { ...todo, isCompleted: !todo.isCompleted };
    }
    return todo;
  });
};

Our toggleTodo helper is more complex because it is immutable. We map through the array of to-dos and update only the specific to-do that we need to toggle.

toggle todo

As you can see, we can now toggle our to-dos.

Toggling all todos

The next feature is to toggle all our to-dos at once, and we have a separate button for it.

const addListeners = () => {
  ...
  selectors.toggleAll.addEventListener("click", (event) => {
    toggleAll(event.target.checked);
  });
};

const toggleAll = (checked) => {
  todos = helpers.toggleAll(todos, checked);
  render();
};

First, we add a click event in our addListeners function and call a toggleAll function. Again, we will use a helper function for this and then call render afterwards.

// todomvc/helpers.js
export const toggleAll = (todos, isCompleted) => {
  return todos.map((todo) => {
    return { ...todo, isCompleted };
  });
};

Our helper function simply updates all to-dos with the new isCompleted value.

toggle all

Now we can toggle and untoggle all items at once.

Editing a todo

The last feature that we will implement is editing our to-dos. The idea is the same: attach a listener, define a function with rendering, and implement a helper function.

const createTodoNode = (todo) => {
  node
    .querySelector("label")
    .addEventListener("dblclick", () => startEditing(node));

  node.querySelector(".edit").addEventListener("keyup", (event) => {
    if (event.key === "Enter") {
      updateTodo(todo.id, event.target.value);
    } else if (event.key === "Escape") {
      event.target.value = todo.title;
      render();
    }
  });

  return node;
};

Here, we added 2 event listeners in createTodoNode. One listens for double clicks to start editing, and the other listens for changes in the editing input. If we hit Enter, we call updateTodo; if we hit Escape, we restore the value.

const startEditing = (node) => {
  node.classList.add("editing");
  node.querySelector(".edit").focus();
};

const updateTodo = (todoId, text) => {
  todos = helpers.updateTodo(todos, todoId, text);
  render();
};

Our startEditing function adds an editing class and focuses on the editing input. updateTodo calls a helper function and then render afterwards.

// todomvc/helpers.js
export const updateTodo = (todos, todoId, newText) => {
  return todos.map((todo) => {
    if (todo.id === todoId) {
      return { ...todo, text: newText };
    }
    return todo;
  });
};

Our updateTodo helper maps through all todos and updates the specific todo with new text.

editing

Now we can edit our todos and they are re-rendered.

Implementing a project with plain JavaScript makes a lot of sense as it brings clarity to how it is done inside frameworks like React or Angular, and how you can implement it without these frameworks.

Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!

📚 Source code of what we've done
Did you like my post? Share it with friends!
Don't miss a thing!
Follow me on Youtube, Twitter or Instagram.
Oleksandr Kocherhin
Oleksandr Kocherhin is a full-stack developer with a passion for learning and sharing knowledge on Monsterlessons Academy and on his YouTube channel. With around 15 years of programming experience and nearly 9 years of teaching, he has a deep understanding of both disciplines. He believes in learning by doing, a philosophy that is reflected in every course he teaches. He loves exploring new web and mobile technologies, and his courses are designed to give students an edge in the fast-moving tech industry.