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