We will refactor the code to incorporate a functional programming paradigm. In functional programming, our focus will be on:
Let’s focus on the renderTodos
function. We are using two for loops to filter the todos based on the current filter setting and then render them to the DOM.
// Function to render the todos based on the current filter
function renderTodos() {
// Clear the current list to avoid duplicates
todoListElement.innerHTML = "";
let filteredTodos = [];
// Filter todos based on the current filter setting
for (let i = 0; i < todos.length; i++) {
// Code to filter todos based on the current filter setting
}
// Loop through the filtered todos and add them to the DOM
for (let i = 0; i < filteredTodos.length; i++) {
// Code to render each todo item
}
}
In JavaScript, arrays have built-in higher-order functions like filter
, map
, reduce
, and forEach
. We can use these functions to refactor the code and make it more “functional.” For instance, for the second loop where we iterate over the filteredTodos
array, we can use the forEach
function to loop through the filteredTodos
array and render each todo item to the DOM. Here is an example of how you can use the forEach
function to render the todos:
filteredTodos.forEach(/* Function to render each todo item */);
We can define a separate function to render each todo item and pass it as an argument to the forEach
function.
function renderTodoItem(todo) {
// Code to render each todo item
}
filteredTodos.forEach(renderTodoItem);
Notice that the forEach
function takes a function as an argument, which will be called for each element in the filteredTodos
array. This is an example of a higher-order function where we pass a function as an argument to another function. In this case, the forEach
function is the higher-order function, and the renderTodoItem
function is the callback function.
The forEach
function will call the renderTodoItem
function (hence it is called the callback function) for each todo item in the filteredTodos
array, passing the todo item as an argument. The renderTodoItem
function will then render the todo item to the DOM.
We can define the renderTodoItem
function right before where we are calling the forEach
function, inside the renderTodos
function. In JavaScript functions can be defined anywhere in the code, including inside other functions. Alternatively, we can define renderTodoItem
separately at the top of the file. This alternative will be useful if we want to reuse the renderTodoItem
function in other parts of the code.
In our case, we will not be reusing the renderTodoItem
function, so defining it inside the renderTodos
function is a good choice. In fact, we can define the renderTodoItem
function right where we are calling the forEach
function as shown below:
filteredTodos.forEach(function renderTodoItem(todo) {
// Code to render each todo item
});
If you define the renderTodoItem
function right where you are calling the forEach
function, you would not be able to reuse it in other parts of the code. If this is not a concern, you can define it inline like this. Moreover, you can make it an anonymous function by removing the function name.
filteredTodos.forEach(function (todo) {
// Code to render each todo item
});
The anonymous function will still be called for each todo item in the filteredTodos
array, but you won’t be able to reuse it in other parts of the code. It has a more concise syntax and is useful when you don’t need to reuse the function. On that note, you can also use arrow functions for an even more concise syntax.
filteredTodos.forEach((todo) => {
// Code to render each todo item
});
Arrow functions are a more concise way to define functions in JavaScript. They have a shorter syntax and are typically used for anonymous functions that are passed as arguments to other functions.
Let’s complete the implementation of this arrow function to render each todo item to the DOM.
// Loop through the filtered todos and add them to the DOM
filteredTodos.forEach((todo) => {
const todoItem = document.createElement("div");
todoItem.classList.add("p-4", "todo-item");
todoListElement.appendChild(todoItem);
const todoText = document.createElement("div");
todoText.id = `todo-text-${todo.id}`;
todoText.classList.add("todo-text");
if (todo.completed) {
todoText.classList.add("line-through");
}
todoText.innerText = todo.text;
todoItem.appendChild(todoText);
const todoEdit = document.createElement("input");
todoEdit.classList.add("hidden", "todo-edit");
todoEdit.value = todo.text;
todoItem.appendChild(todoEdit);
})
The body of the arrow function is the same as the code that was inside the second loop. We are creating a div
element for each todo item, setting the text content, adding classes based on the completed
property, and appending the elements to the DOM. Replace the second loop with this code snippet to render the todos using the forEach
function.
We can further refactor the code by extracting the logic to create the todo text element and the todo edit input element into separate helper functions. This will make the code more modular and easier to read.
// Helper function to create todo text element
const createTodoText = (todo) => {
const todoText = document.createElement("div");
todoText.id = `todo-text-${todo.id}`;
todoText.classList.add(
"todo-text",
...(todo.completed ? ["line-through"] : []),
);
todoText.innerText = todo.text;
return todoText;
};
// Helper function to create todo edit input element
const createTodoEditInput = (todo) => {
const todoEdit = document.createElement("input");
todoEdit.classList.add("hidden", "todo-edit");
todoEdit.value = todo.text;
return todoEdit;
};
// Helper function to create a todo item
const createTodoItem = (todo) => {
const todoItem = document.createElement("div");
todoItem.classList.add("p-4", "todo-item");
todoItem.append(createTodoText(todo), createTodoEditInput(todo));
return todoItem;
};
Now we can use these helper functions inside the forEach
function to create the todo text and edit input elements and append them to the todo item.
filteredTodos.forEach(todo => {
todoListElement.appendChild(createTodoItem(todo));
});
Here is another way of performing the same operation using the map
function.
const todoElements = filteredTodos.map(createTodoItem);
todoListElement.append(...todoElements);
The map
function creates a new array with the results of calling a provided function on every element in the calling array. In this case, we are creating an array of todo elements by mapping each todo item to a todo element using the createTodoItem
function. We then append all the todo elements to the todoListElement
using the spread operator ...
.
Both of these approaches are valid and achieve the same result. The choice between using forEach
and map
depends on the specific requirements of your application. The map
function is often preferred when you want to transform each element in an array and create a new array with the transformed elements. The forEach
function is used when you want to perform an operation on each element in an array without creating a new array.
As part of rendering the todos, we are filtering the todos based on the current filter setting. We can refactor the code to use the filter
function, which is a higher-order function that creates a new array with all elements that pass the test implemented by the provided function.
For simplicity, let’s start by considering that our goal is to show only completed todos. We can use the filter
function as follows:
const completedTodos = todos.filter((todo) => {
if (todo.completed) {
return true;
} else {
return false;
}
});
Notice how the filter
function takes a function as an argument that returns a boolean value. The filter
function will call this function for each element in the todos
array and create a new array containing only the elements for which the function returns true
. In this case, we are filtering the todos array to create a new array containing only the completed todos.
We can simplify the code by using a ternary operator to return true
or false
based on the completed
property of the todo.
const completedTodos = todos.filter((todo) => {
return todo.completed ? true : false;
});
Given the todo.completed
property is already a boolean value, we can further simplify the code by directly returning the todo.completed
property.
const completedTodos = todos.filter((todo) => {
return todo.completed;
});
In arrow functions, if the function body consists of a single expression, you can omit the curly braces {}
and the return
keyword. The expression will be implicitly returned. You can also eliminate the parentheses around the parameter if there is only one parameter.
const completedTodos = todos.filter(todo => todo.completed);
Now this is where we can claim the code is a more readable and concise way to filter the todos array.
A fundamental difference between filter
and forEach
is that filter
creates a new array with the elements that pass the condition, while forEach
simply iterates over the elements without creating a new array. This is an important distinction to keep in mind when using these functions.
In our case we need to consider the filter settings to show completed, active, or all todos. We can refactor the code to use the filter
function with a conditional statement to filter the todos based on the current filter setting. We can define a helper function to filter the todos based on the current filter setting.
// Helper function to filter todos based on the current filter setting
const filterTodos = (todos, filter) => {
switch (filter) {
case 'all': return [...todos];
case 'completed': return todos.filter(todo => todo.completed);
case 'active': return todos.filter(todo => !todo.completed);
}
};
Pay attention to the use of the spread operator ...
when returning all todos. This is to ensure that we are returning a new array with the same elements as the original todos
array. This is an example of immutability, a key concept in functional programming. The filter
function is a good example of this principle, as it creates a new array with the filtered elements without modifying the original array.
With this function in place, we can refactor the renderTodos
function to use the filterTodos
function to get the filtered todos and then render them to the DOM.
// Function to render the todos based on the current filter
const renderTodos = () => {
todoListElement.innerHTML = ''; // Clear the current list to avoid duplicates
const filteredTodos = filterTodos(todos, filter);
const todoElements = filteredTodos.map(createTodoItem);
todoListElement.append(...todoElements);
};
This refactoring makes the code more modular and easier to read. The renderTodos
function now uses the filterTodos
function to get the filtered todos based on the current filter setting and then uses the map
function to create an array of todo elements. Finally, it appends the todo elements to the todoListElement
.
Since the return value of the filterTodos
function is a new array containing the filtered todos, we can directly call forEach
function on this array to render the todos to the DOM.
// Function to render the todos based on the current filter
const renderTodos = () => {
todoListElement.innerHTML = ''; // Clear the current list to avoid duplicates
const filteredTodos = filterTodos(todos, filter);
const todoElements = filteredTodos.map(createTodoItem);
todoListElement.append(...todoElements);
};
Alternatively, I can reduce this code to a single line by chaining the filterTodos
and map
function, and using replaceChildren
functions together with the spread operator ...
to replace the children of the todoListElement
.
// Function to render the todos based on the current filter
const renderTodos = () => {
todoListElement.replaceChildren(...filterTodos(todos, filter).map(createTodoItem));
};
This is another common pattern in functional programming where you chain multiple functions together to transform data. There is also a tendency among functional programmers to write code in the form of a pipeline, where data flows through a series of functions, each transforming the data in some way. I personally prefer the first version of the code as it is more readable and easier to understand.
Currently, we push a new todo item directly to the todos
array when adding a new todo item.
todos.push({ id: nextTodoId++, text: todoText, completed: false });
As it was pointed out earlier, in functional programming, we aim to avoid mutating the original data and instead create new data structures. In this case, we are directly pushing a new todo item to the todos
array, which mutates the original array. To adhere to the functional programming principle of immutability, we can create a new array with the existing todos and the new todo item.
// Helper function to create a new array with the existing todos and a new todo item
const addTodo = (todos, newTodoText) => [
...todos,
{ id: nextTodoId++, text: newTodoText, completed: false },
];
The addTodo
function creates a new array by spreading the existing todos
array and adding the new todo item to the end. In our application, we should use the return value of this function to replace the todos
array. To that aim, we should change the todos
array declaration from const
to let
.
- const todos = [
+ let todos = [
{ id: 1, text: "Buy milk", completed: false },
{ id: 2, text: "Buy bread", completed: false },
{ id: 3, text: "Buy jam", completed: true },
];
Now, we can use the addTodo
function to add a new todo item to the todos
array without mutating the original array.
// Event handler to create a new todo item
const handleKeyDownToCreateNewTodo = (event) => {
if (event.key === "Enter") {
const todoText = event.target.value.trim();
if (todoText) {
todos = addTodo(todos, todoText);
event.target.value = ""; // Clear the input
renderTodos();
}
}
};
You might be wondering what is the point of this change. In this simple example, it might not seem necessary to avoid mutating the original array. However, in more complex applications, immutability can help prevent bugs and make the code easier to reason about. This patterns is also used in libraries like React to manage state updates. If your React component state is an object or an array, you should never mutate it directly. Instead, you should create a new object or array with the updated values.
When marking a todo item as completed, we directly toggle the completed
property of the todo item.
for (let i = 0; i < todos.length; i++) {
if (todos[i].id === todoIdNumber) {
todos[i].completed = !todos[i].completed;
}
}
Let’s refactor this code to use the map
function:
// Helper function to toggle the completed status of a todo item
const toggleTodo = (todos, todoId) => todos.map(todo =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
);
The toggleTodo
function takes the todos
array and the todoId
as arguments. It uses the map
function to create a new array by iterating over the todos
array. For each todo item, it checks if the id
matches the todoId
. If it matches, it creates a new todo object with the completed
property toggled. If it doesn’t match, it returns the original todo object. This way, we are creating a new array with the updated todo item without mutating the original array.
Now, we can use the toggleTodo
function to toggle the completed status of a todo item. In the refactoring process, we also create a few helper functions to make the code more readable and modular.
// Helper function to find the target todo element
const findTargetTodoElement = (event) =>
event.target.id?.includes("todo-text") ? event.target : null;
// Helper function to parse the todo id from the todo element
const parseTodoId = (todo) => (todo ? Number(todo.id.split("-").pop()) : -1);
// Event handler to toggle the completed status of a todo item
const handleClickOnTodoList = (event) => {
todos = toggleTodo(todos, parseTodoId(findTargetTodoElement(event)));
renderTodos();
};
When selecting a filter, we directly update the filter
variable.
Let’s refactor the code that toggles the completed status of a todo item. Currently, we are using a loop to find the todo item with the matching id
and toggle its completed
status.
// Function to toggle the completed status of a todo
function handleClickOnTodoList(event) {
let todo = null;
if (event.target.id !== null && event.target.id.includes("todo-text")) {
todo = event.target;
}
let todoIdNumber = -1;
if (todo) {
const todoId = event.target.id.split("-").pop();
todoIdNumber = Number(todoId);
}
for (let i = 0; i < todos.length; i++) {
if (todos[i].id === todoIdNumber) {
todos[i].completed = !todos[i].completed;
}
}
// Re-render the app UI
renderTodos();
}
In this code snippet, we are iterating over the todos
array to find the todo item with the matching id
and toggle its completed
status. We can refactor this code to use the find
function, which returns the first element in the array that satisfies the provided testing function.
// Function to toggle the completed status of a todo
function handleClickOnTodoList(event) {
let todo = null;
if (event.target.id !== null && event.target.id.includes("todo-text")) {
todo = event.target;
}
let todoIdNumber = -1;
if (todo) {
const todoId = event.target.id.split("-").pop();
todoIdNumber = Number(todoId);
}
const todoToUpdate = todos.find((todo) => todo.id === todoIdNumber);
if (todoToUpdate) {
todoToUpdate.completed = !todoToUpdate.completed;
}
// Re-render the app UI
renderTodos();
}
In this refactored code, we are using the find
function to find the todo item with the matching id
and toggle its completed
status. The find
function returns the first element in the array that satisfies the provided testing function. If a todo item with the matching id
is found, we toggle its completed
status and then re-render the app UI.
renderTodoNavBar
and handleClickOnNavbar
Let’s refactor the handleClickOnNavbar
and renderTodoNavBar
functions to make them align with the other refactored functions.
// Helper function to update the class list of a navbar element
const updateClassList = (element, isActive) => {
const classes = [
"underline",
"underline-offset-4",
"decoration-rose-800",
"decoration-2",
];
if (isActive) {
element.classList.add(...classes);
} else {
element.classList.remove(...classes);
}
}
// Helper function to render the navbar anchor elements
const renderTodoNavBar = (href) => {
Array.from(todoNav.children).forEach((element) => {
updateClassList(element, element.href === href);
});
}
// Event handler to filter the todos based on the navbar selection
const handleClickOnNavbar = (event) => {
// if the clicked element is an anchor tag
if (event.target.tagName === "A") {
const hrefValue = event.target.href;
filter = hrefValue.split("/").pop() || "all";
renderTodoNavBar(hrefValue);
renderTodos();
}
}
Here are the key changes made in the refactored code:
Arrow Functions: Both functions are converted to arrow functions, making them consistent with the rest of the codebase that uses this more concise function syntax.
Helper Functions: The logic for updating the class list of a navbar element is extracted into a separate helper function updateClassList
. This function takes an element and a boolean flag isActive
to determine whether to add or remove classes.
Defaulting to “all”: The action assignment uses the logical OR (||
) to default to “all” when splitting and popping result in an empty string.
Array.from and forEach: Instead of using a loop, Array.from
is used to convert todoNav.children
(which is an HTMLCollection) to an array, allowing the use of forEach
for iteration.
Let’s refactor the code to create a factory function that encapsulates the todo app logic. This function will return an object with methods to interact with the todo app.
// Factory function to create a todo app
const createTodoApp = () => {
// Define the state of our app
let todos = [];
let nextTodoId = 1;
let filter = "all"; // can be 'all', 'active', or 'completed'
return {
addTodo: (newTodoText) => {
todos = addTodo(todos, newTodoText, nextTodoId++);
},
toggleTodo: (todoId) => {
todos = toggleTodo(todos, todoId);
},
setFilter: (newFilter) => {
filter = newFilter;
},
getTodos: () => filterTodos(todos, filter),
};
};
In this factory function, we define the initial state of our app (todos, nextTodoId, and filter) and return an object with methods to interact with the todo app. The addTodo
, toggleTodo
, setFilter
, and getTodos
methods encapsulate the logic for adding a new todo item, toggling the completed status of a todo item, setting the filter, and getting the filtered todos, respectively.
We are using a functional programming techncique called closures to maintain the state of the todo app within the factory function. The todos
, nextTodoId
, and filter
variables are accessible within the returned object’s methods due to the closure created by the factory function. However, these variables are not directly accessible outside the factory function, ensuring data encapsulation.
Notice the helper functions addTodo
, toggleTodo
, and filterTodos
were already defined in earlier steps. I have updated the addTodo
function to include the newTodoId
parameter, which is used to assign a unique id to each new todo item.
// Helper function to create a new array with the existing todos and a new todo item
const addTodo = (todos, newTodoText, newTodoId) => [
...todos,
{ id: newTodoId, text: newTodoText, completed: false },
];
// Helper function to toggle the completed status of a todo item
const toggleTodo = (todos, todoId) =>
todos.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
);
// Helper function to filter todos based on the current filter setting
const filterTodos = (todos, filter) => {
switch (filter) {
case "all":
return [...todos];
case "completed":
return todos.filter((todo) => todo.completed);
case "active":
return todos.filter((todo) => !todo.completed);
}
};
You should now use the factory function to create a todo app instance and interact with it using the defined methods.
const todoApp = createTodoApp();
You can now use the todoApp
object to interact with the todo app. For example, to add a new todo item, you can call the addTodo
method.
// Event handler to create a new todo item
const handleKeyDownToCreateNewTodo = (event) => {
if (event.key === "Enter") {
const todoText = event.target.value.trim();
if (todoText) {
todoApp.addTodo(todoText);
event.target.value = ""; // Clear the input
renderTodos();
}
}
};
Here is the final refactored code that incorporates all the changes we made in the previous steps.
import "../style.css";
// Get the necessary DOM elements
const todoListElement = document.getElementById("todo-list");
const inputNewTodo = document.getElementById("new-todo");
const todoNav = document.getElementById("todo-nav");
// Helper function to create a new array with the existing todos and a new todo item
const addTodo = (todos, newTodoText, newTodoId) => [
...todos,
{ id: newTodoId, text: newTodoText, completed: false },
];
// Helper function to toggle the completed status of a todo item
const toggleTodo = (todos, todoId) =>
todos.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
);
// Helper function to filter todos based on the current filter setting
const filterTodos = (todos, filter) => {
switch (filter) {
case "all":
return [...todos];
case "completed":
return todos.filter((todo) => todo.completed);
case "active":
return todos.filter((todo) => !todo.completed);
}
};
// Factory function to create a todo app
const createTodoApp = () => {
// Define the state of our app
let todos = [];
let nextTodoId = 1;
let filter = "all"; // can be 'all', 'active', or 'completed'
return {
addTodo: (newTodoText) => {
todos = addTodo(todos, newTodoText, nextTodoId++);
},
toggleTodo: (todoId) => {
todos = toggleTodo(todos, todoId);
},
setFilter: (newFilter) => {
filter = newFilter;
},
getTodos: () => filterTodos(todos, filter),
};
};
const todoApp = createTodoApp();
// Helper function to create todo text element
const createTodoText = (todo) => {
const todoText = document.createElement("div");
todoText.id = `todo-text-${todo.id}`;
todoText.classList.add(
"todo-text",
...(todo.completed ? ["line-through"] : []),
);
todoText.innerText = todo.text;
return todoText;
};
// Helper function to create todo edit input element
const createTodoEditInput = (todo) => {
const todoEdit = document.createElement("input");
todoEdit.classList.add("hidden", "todo-edit");
todoEdit.value = todo.text;
return todoEdit;
};
// Helper function to create a todo item
const createTodoItem = (todo) => {
const todoItem = document.createElement("div");
todoItem.classList.add("p-4", "todo-item");
todoItem.append(createTodoText(todo), createTodoEditInput(todo));
return todoItem;
};
// Function to render the todos based on the current filter
const renderTodos = () => {
todoListElement.innerHTML = ""; // Clear the current list to avoid duplicates
const todoElements = todoApp.getTodos().map(createTodoItem);
todoListElement.append(...todoElements);
};
// Event handler to create a new todo item
const handleKeyDownToCreateNewTodo = (event) => {
if (event.key === "Enter") {
const todoText = event.target.value.trim();
if (todoText) {
todoApp.addTodo(todoText);
event.target.value = ""; // Clear the input
renderTodos();
}
}
};
// Helper function to find the target todo element
const findTargetTodoElement = (event) =>
event.target.id?.includes("todo-text") ? event.target : null;
// Helper function to parse the todo id from the todo element
const parseTodoId = (todo) => (todo ? Number(todo.id.split("-").pop()) : -1);
// Event handler to toggle the completed status of a todo item
const handleClickOnTodoList = (event) => {
todoApp.toggleTodo(parseTodoId(findTargetTodoElement(event)));
renderTodos();
};
// Helper function to update the class list of a navbar element
const updateClassList = (element, isActive) => {
const classes = [
"underline",
"underline-offset-4",
"decoration-rose-800",
"decoration-2",
];
if (isActive) {
element.classList.add(...classes);
} else {
element.classList.remove(...classes);
}
};
// Helper function to render the navbar anchor elements
const renderTodoNavBar = (href) => {
Array.from(todoNav.children).forEach((element) => {
updateClassList(element, element.href === href);
});
};
// Event handler to filter the todos based on the navbar selection
const handleClickOnNavbar = (event) => {
// if the clicked element is an anchor tag
if (event.target.tagName === "A") {
const hrefValue = event.target.href;
todoApp.setFilter(hrefValue.split("/").pop() || "all");
renderTodoNavBar(hrefValue);
renderTodos();
}
};
// Add the event listeners
todoListElement.addEventListener("click", handleClickOnTodoList);
inputNewTodo.addEventListener("keydown", handleKeyDownToCreateNewTodo);
todoNav.addEventListener("click", handleClickOnNavbar);
document.addEventListener("DOMContentLoaded", renderTodos);
Let’s summarize the changes made in this refactoring process and reflect on how they align with the principles of functional programming:
Higher-Order Functions: We used higher-order functions like map
, filter
, and forEach
to refactor the code and make it more functional. These functions take other functions as arguments or return functions, allowing us to write more concise and expressive code.
Closures: We used closures to maintain the state of the todo app within the factory function. The todos
, nextTodoId
, and filter
variables are accessible within the returned object’s methods due to the closure created by the factory function.
Immutable Data: We avoided mutating the original data and instead created new data structures. For example, when adding a new todo item or toggling the completed status of a todo item, we created new arrays with the updated values without modifying the original arrays.
Pure Functions: We aimed to write pure functions that always produce the same output for the same input and have no side effects. The helper functions addTodo
, toggleTodo
, and filterTodos
are pure functions that take input and return output without modifying external state.
By following these principles and refactoring the code to adopt a more functional programming paradigm, we have made the code more modular, readable, and maintainable. We have also learned how to use higher-order functions, closures, and immutable data to write cleaner and more expressive code.
Let’s add a feature to mark all todos as completed. There is a button with the id mark-all-completed
that we can use to trigger this action.
const markAllCompleted = document.getElementById("mark-all-completed");
// Event handler to mark all todos as completed
const handleMarkAllCompleted = () => {}
markAllCompleted.addEventListener("click", handleMarkAllCompleted);
Before implementing the handleMarkAllCompleted
function, let’s define a helper function to toggle the status of all todos to completed.
// Helper function to mark all todos as completed
const markAllTodosCompleted = (todos) => {
return todos.map((todo) => {
return { ...todo, completed: true };
});
};
We are using the map
function to create a new array with all todos marked as completed. The markAllTodosCompleted
function takes the todos
array as input and returns a new array with all todos marked as completed.
Let’s update the factory function to include a method to mark all todos as completed.
// Factory function to create a todo app
const createTodoApp = () => {
// Define the state of our app
let todos = [];
let nextTodoId = 1;
let filter = "all"; // can be 'all', 'active', or 'completed'
return {
addTodo: (newTodoText) => {
todos = addTodo(todos, newTodoText, nextTodoId++);
},
toggleTodo: (todoId) => {
todos = toggleTodo(todos, todoId);
},
setFilter: (newFilter) => {
filter = newFilter;
},
markAllCompleted: () => {
todos = markAllTodosCompleted(todos);
},
getTodos: () => filterTodos(todos, filter),
};
};
Now, we can implement the handleMarkAllCompleted
function to mark all todos as completed.
// Event handler to mark all todos as completed
const handleMarkAllCompleted = () => {
todoApp.markAllCompleted();
renderTodos();
}
Run the code and test the “Mark All Completed” feature by clicking the “Mark All Completed” button. You should see all todos marked as completed.
Let’s add a feature to clear all completed todos. There is a button with the id clear-completed
that we can use to trigger this action.
const clearCompleted = document.getElementById("clear-completed");
// Event handler to clear all completed todos
const clearCompletedTodos = () => {};
clearCompleted.addEventListener("click", clearCompletedTodos);
Before implementing the clearCompletedTodos
function, let’s define a helper function to filter out completed todos.
// Helper function to delete all completed todos
const deleteCompletedTodos = (todos) => {
return todos.filter((todo) => !todo.completed);
};
We are using the filter
function to create a new array with all completed todos filtered out. The deleteCompletedTodos
function takes the todos
array as input and returns a new array with all completed todos removed.
Let’s update the factory function to include a method to delete all completed todos.
// Factory function to create a todo app
const createTodoApp = () => {
// Define the state of our app
let todos = [];
let nextTodoId = 1;
let filter = "all"; // can be 'all', 'active', or 'completed'
return {
addTodo: (newTodoText) => {
todos = addTodo(todos, newTodoText, nextTodoId++);
},
toggleTodo: (todoId) => {
todos = toggleTodo(todos, todoId);
},
setFilter: (newFilter) => {
filter = newFilter;
},
markAllCompleted: () => {
todos = markAllTodosCompleted(todos);
},
deleteCompleted: () => {
todos = deleteCompletedTodos(todos);
},
getTodos: () => filterTodos(todos, filter),
};
};
Now, we can implement the clearCompletedTodos
function to delete all completed todos.
// Event handler to clear all completed todos
const clearCompletedTodos = () => {
todoApp.deleteCompleted();
renderTodos();
};
Run the code and test the “Clear Completed” feature by clicking the “Clear Completed” button. You should see all completed todos removed from the list.
There is a span element with the id todo-count
that we can use to display the number of active todos. Let’s get hold of this element and update it with the count of active todos.
const activeTodosCount = document.getElementById("todo-count");
Next, let’s update the factory function to include a method to get the number of active todos.
// Factory function to create a todo app
const createTodoApp = () => {
// Define the state of our app
let todos = [];
let nextTodoId = 1;
let filter = "all"; // can be 'all', 'active', or 'completed'
return {
addTodo: (newTodoText) => {
todos = addTodo(todos, newTodoText, nextTodoId++);
},
toggleTodo: (todoId) => {
todos = toggleTodo(todos, todoId);
},
setFilter: (newFilter) => {
filter = newFilter;
},
markAllCompleted: () => {
todos = markAllTodosCompleted(todos);
},
deleteCompleted: () => {
todos = deleteCompletedTodos(todos);
},
getNumberOfActiveTodos: () =>
todos.reduce((acc, todo) => acc + !todo.completed, 0),
getTodos: () => filterTodos(todos, filter),
};
};
Here we are using the reduce
function to calculate the number of active todos. The reduce
function takes an accumulator acc
and the current todo item as arguments. For each todo item, it increments the accumulator by 1 if the todo is not completed. The initial value of the accumulator is set to 0.
Let’s update the renderTodos
function to display the number of active todos.
// Function to render the todos based on the current filter
const renderTodos = () => {
todoListElement.innerHTML = ""; // Clear the current list to avoid duplicates
const todoElements = todoApp.getTodos().map(createTodoItem);
todoListElement.append(...todoElements);
activeTodosCount.innerText = `${todoApp.getNumberOfActiveTodos()} item${todoApp.getNumberOfActiveTodos() === 1 ? "" : "s"} left`;
};
Here we are updating the innerText
of the activeTodosCount
element to display the number of active todos. We are using a template literal to display the count and handle the pluralization of the word “item” based on the count.
Run the code and test the display of the number of active todos. You should see the count of active todos displayed on the screen.
Here is the final refactored code that incorporates all the changes we made in the previous steps.
import "../style.css";
// Get the necessary DOM elements
const todoListElement = document.getElementById("todo-list");
const inputNewTodo = document.getElementById("new-todo");
const todoNav = document.getElementById("todo-nav");
const markAllCompleted = document.getElementById("mark-all-completed");
const clearCompleted = document.getElementById("clear-completed");
const activeTodosCount = document.getElementById("todo-count");
// Helper function to create a new array with the existing todos and a new todo item
const addTodo = (todos, newTodoText, newTodoId) => [
...todos,
{ id: newTodoId, text: newTodoText, completed: false },
];
// Helper function to toggle the completed status of a todo item
const toggleTodo = (todos, todoId) =>
todos.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
);
// Helper function to filter todos based on the current filter setting
const filterTodos = (todos, filter) => {
switch (filter) {
case "all":
return [...todos];
case "completed":
return todos.filter((todo) => todo.completed);
case "active":
return todos.filter((todo) => !todo.completed);
}
};
// Helper function to mark all todos as completed
const markAllTodosCompleted = (todos) => {
return todos.map((todo) => {
return { ...todo, completed: true };
});
};
// Helper function to delete all completed todos
const deleteCompletedTodos = (todos) => {
return todos.filter((todo) => !todo.completed);
};
// Factory function to create a todo app
const createTodoApp = () => {
// Define the state of our app
let todos = [];
let nextTodoId = 1;
let filter = "all"; // can be 'all', 'active', or 'completed'
return {
addTodo: (newTodoText) => {
todos = addTodo(todos, newTodoText, nextTodoId++);
},
toggleTodo: (todoId) => {
todos = toggleTodo(todos, todoId);
},
setFilter: (newFilter) => {
filter = newFilter;
},
markAllCompleted: () => {
todos = markAllTodosCompleted(todos);
},
deleteCompleted: () => {
todos = deleteCompletedTodos(todos);
},
getNumberOfActiveTodos: () =>
todos.reduce((acc, todo) => acc + !todo.completed, 0),
getTodos: () => filterTodos(todos, filter),
};
};
const todoApp = createTodoApp();
// Helper function to create todo text element
const createTodoText = (todo) => {
const todoText = document.createElement("div");
todoText.id = `todo-text-${todo.id}`;
todoText.classList.add(
"todo-text",
...(todo.completed ? ["line-through"] : []),
);
todoText.innerText = todo.text;
return todoText;
};
// Helper function to create todo edit input element
const createTodoEditInput = (todo) => {
const todoEdit = document.createElement("input");
todoEdit.classList.add("hidden", "todo-edit");
todoEdit.value = todo.text;
return todoEdit;
};
// Helper function to create a todo item
const createTodoItem = (todo) => {
const todoItem = document.createElement("div");
todoItem.classList.add("p-4", "todo-item");
todoItem.append(createTodoText(todo), createTodoEditInput(todo));
return todoItem;
};
// Function to render the todos based on the current filter
const renderTodos = () => {
todoListElement.innerHTML = ""; // Clear the current list to avoid duplicates
const todoElements = todoApp.getTodos().map(createTodoItem);
todoListElement.append(...todoElements);
activeTodosCount.innerText = `${todoApp.getNumberOfActiveTodos()} item${todoApp.getNumberOfActiveTodos() === 1 ? "" : "s"} left`;
};
// Event handler to create a new todo item
const handleKeyDownToCreateNewTodo = (event) => {
if (event.key === "Enter") {
const todoText = event.target.value.trim();
if (todoText) {
todoApp.addTodo(todoText);
event.target.value = ""; // Clear the input
renderTodos();
}
}
};
// Helper function to find the target todo element
const findTargetTodoElement = (event) =>
event.target.id?.includes("todo-text") ? event.target : null;
// Helper function to parse the todo id from the todo element
const parseTodoId = (todo) => (todo ? Number(todo.id.split("-").pop()) : -1);
// Event handler to toggle the completed status of a todo item
const handleClickOnTodoList = (event) => {
todoApp.toggleTodo(parseTodoId(findTargetTodoElement(event)));
renderTodos();
};
// Helper function to update the class list of a navbar element
const updateClassList = (element, isActive) => {
const classes = [
"underline",
"underline-offset-4",
"decoration-rose-800",
"decoration-2",
];
if (isActive) {
element.classList.add(...classes);
} else {
element.classList.remove(...classes);
}
};
// Helper function to render the navbar anchor elements
const renderTodoNavBar = (href) => {
Array.from(todoNav.children).forEach((element) => {
updateClassList(element, element.href === href);
});
};
// Event handler to filter the todos based on the navbar selection
const handleClickOnNavbar = (event) => {
// if the clicked element is an anchor tag
if (event.target.tagName === "A") {
const hrefValue = event.target.href;
todoApp.setFilter(hrefValue.split("/").pop() || "all");
renderTodoNavBar(hrefValue);
renderTodos();
}
};
// Event handler to mark all todos as completed
const handleMarkAllCompleted = () => {
todoApp.markAllCompleted();
renderTodos();
};
// Event handler to clear all completed todos
const clearCompletedTodos = () => {
todoApp.deleteCompleted();
renderTodos();
};
// Add the event listeners
todoListElement.addEventListener("click", handleClickOnTodoList);
inputNewTodo.addEventListener("keydown", handleKeyDownToCreateNewTodo);
todoNav.addEventListener("click", handleClickOnNavbar);
markAllCompleted.addEventListener("click", handleMarkAllCompleted);
clearCompleted.addEventListener("click", clearCompletedTodos);
document.addEventListener("DOMContentLoaded", renderTodos);
This final code includes all the features we implemented in the previous steps, such as adding a new todo item, toggling the completed status of a todo item, filtering todos based on the current filter, marking all todos as completed, clearing completed todos, and displaying the number of active todos. The code is modular, readable, and follows the principles of functional programming.