17
To-do list with Observer Pattern
In this post, we'll be learning about the Observer Pattern by creating a simple To-do application.
In a nutshell, the Observer Pattern is similar to Twitter's Followers feature. When you post a tweet, all your followers get notified, and they decide whether to read your tweet or not. We can say our Followers are observing our tweets.
The Observer Pattern has only two components. The Subject and the Observers. The Observers only want to know when we update the Subject. They don't care when it happens.
Going back to our Twitter analogy, our Tweet is the Subject, while our Followers are the Observers.
So, how does it relate to our Todo list application? We'll uncover the answer while we build the app, but first, we need to know the features of our app.
- We want to be able to add a unique to-do to our list
- We want to be able to remove a to-do from our list
- We want to persist our list on page reload
Let's create the HTML of our Todo app.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Observer Pattern</title>
</head>
<body>
<ul></ul>
<form>
<input required type="text" />
<button type="submit">Add</button>
</form>
</body>
<script>
// We'll add all our code here
</script>
</html>
In this HTML, we have an unordered list element which will hold our todo items, a form element to add a todo item to our list, and finally a script
element to hold our JavaScript code.
The Subject will be our todo items. So we create an array list to store our todos.
<script>
let todos = []; // Subject
</script>
Next we create a list of Observers. (Functions that will make use of the list).
<script>
let todos = []; // Subject
let observers = [];
</script>
Then, we implement the add todo functionality. Each todo needs to be uniquely identified, so assign each item with an ID.
const form = document.querySelector("form");
form.addEventListener('submit', (event) => {
event.preventDefault();
const input = form.elements[0];
const item = {
id: Date.now(),
description: input.value,
};
addTodo(item);
input.value = ''; // Clear text input
});
function addTodo(item) {
todos.push(item);
}
When you try running the app, you'll notice nothing is being displayed on screen. That's because we haven't hooked up our todos
array to our HTML unordered list element.
Our HTML ul
element is interested in our todos
array. It wants to observe our array list so that it can display it on the screen. So it wants to be an Observer. Let's implement a function that will display our list.
function displayTodos() {
const ul = document.querySelector('ul');
todos.forEach((todo) => {
const li = document.createElement('li');
li.innerText = todo.description;
ul.appendChild(li);
});
}
Now, we register this function as an Observer by adding it to our list of observers
. To do that we create a helper function to register
new observers.
function registerObserver(observer) {
// The observers array is basically an array of functions
observers.push(observer);
}
registerObserver(displayTodos);
Despite registering as an observer, nothing is being displayed. That's because our todos
array hasn't notified the observers.
We create a notifyObservers
function that will loop through our observers
array and call each observer
function to know an update has happened.
function notifyObservers() {
observers.forEach((observer) => observer());
}
Then, we call the notifyObservers
function whenever we change the Subject.
function addTodo(item) {
todos.push(item);
notifyObservers(); // Add this line
}
Now, run the app in your browser and see your todos being added to the list.
You've probably noticed that our list doubles every time we add a new item. We can fix that by clearing it first.
// Inside the displayTodos function
function displayTodos() {
const ul = document.querySelector('ul');
ul.innerHTML = ''; // Add this line
Now that we have "add" functionality working, it's time to remove todos. First, we add a remove button
to every li
element.
function displayTodos() {
const ul = document.querySelector('ul');
ul.innerHTML = '';
todos.forEach((todo) => {
const li = document.createElement('li');
li.innerText = todo.description;
// Add these lines
const button = document.createElement('button');
button.innerText = 'Remove';
li.appendChild(button);
ul.appendChild(li);
});
}
Then, we create a removeTodo
function that will handle removing to-dos by their ID.
function removeTodo(id) {
todos = todos.filter((todo) => todo.id !== id);
notifyObservers();
}
Then we attach a click
event listener to the remove button, that will call the removeTodo
function.
// Inside the displayTodos function
const button = document.createElement('button');
button.innerText = 'Remove';
// Attach an event listener here
button.addEventListener('click', () => {
removeTodo(todo.id);
});
li.appendChild(button)
The final step is to save our list in local storage and load it when we reload the page. We want our local storage to be an observer, and save the list whenever it is notified.
function persistData() {
localStorage.setItem("saved-todos", JSON.stringify(todos));
}
registerObserver(persistData);
Then, we load the saved todos on page load.
function loadTodos(todoList) {
todos = todoList;
notifyObservers();
}
window.addEventListener("load", () => {
const savedTodos = localStorage.getItem("saved-todos");
if (savedTodos) {
loadTodos(JSON.parse(savedTodos));
}
});
Our code is working. It meets the minimum requirements, but it's not elegant. If you follow closely, you'll notice there are 2 kinds of code. Those that manipulate the unordered list element and those that manipulate the todos
array list. We are mixing UI logic and State logic, which is an attribute of a messy code.
Let us start by wrapping our state logic in a function and exposing the register
, add
, remove
, and load
functions as methods to an object. This is called Abstraction.
Our todos
array is no longer visible to the UI logic code. So we create the getTodos
method for accessing the todos
. This is called Encapsulation. The art of hiding internal state and exposing it via a method.
function createSubject() {
let todos = [];
let observers = [];
function registerObserver(observer) {
observers.push(observer);
}
function notifyObservers() {
observers.forEach((observer) => observer());
}
function addTodo(item) {
todos.push(item);
notifyObservers();
}
function removeTodo(id) {
todos = todos.filter((todo) => todo.id !== id);
notifyObservers();
}
function loadTodos(todoList) {
todos = todoList;
notifyObservers();
}
function getState() {
return todos;
}
return {
registerObserver,
addTodo,
removeTodo,
loadTodos,
getState,
}
}
Next we use the createSubject
to create a todos subject.
const subject = createSubject();
function displayTodos() {
const ul = document.querySelector("ul");
ul.innerHTML = "";
todos.forEach((todo) => {
const li = document.createElement("li");
li.innerText = todo.description;
const button = document.createElement("button");
button.innerText = "Remove";
button.addEventListener("click", () => {
subject.removeTodo(todo.id);
});
li.appendChild(button);
ul.appendChild(li);
});
}
subject.registerObserver(displayTodos)
subject.registerObserver(() => {
localStorage.setItem("saved-todos", JSON.stringify(todos));
});
window.addEventListener("load", () => {
const savedTodos = localStorage.getItem("saved-todos");
if (savedTodos) {
subject.loadTodos(JSON.parse(savedTodos));
}
const form = document.querySelector("form");
form.addEventListener("submit", (event) => {
event.preventDefault();
const input = form.elements[0];
const item = {
id: Date.now(),
description: input.value,
};
subject.addTodo(item);
input.value = "";
});
});
The createSubject
function adheres to the Observer Pattern design. We subscribe to the todos by registering as observers. What about if we no longer want to be notified?
It's quite simple. We can return a function in the registerObserver
method.
function registerObserver(observer) {
observers.push(observer);
return function () {
observers = observers.filter((currentObserver) => !== observer);
}
}
Then, we can save the return value after registering and call it later to unregister.
const unregisterDisplayTodos = subject.registerObserver(displayTodos)
// later when we want to unregister
unregisterDisplayTodos(); // displayTodos will no longer be notified
Redux is a popular JavaScript library that uses the Observer Pattern. In the next post, we'll demystify redux by creating our own small redux library.
Happy Coding!
17