A step-by-step tutorial showing you how to
build a Todo List App from scratch in JavaScript
.
Before you continue, try the demo: https://todomvc-app.herokuapp.com
Add a few items to the list. Double-click/tap the item to edit it. Check-off your todos and navigate the footer to filter for Active/Completed. Try and "break" it! Refresh the page and notice how your todo items are "still there" (they were saved to
localStorage
!). Once you have had a "play" with the demo, come back and build it!!
The purpose of this Todo List mini project is to practice your "VanillaJS" skills and consolidate your understanding of The Elm Architecture (TEA) by creating a real world useable App following strict Documentation and Test Driven Development.
This will show you that it's not only possible
to write docs and tests first,
you will see first hand that code
is more concise,
well-documented and thus easier to maintain
and you will get your work done much faster.
These are foundational skills that will
pay immediate returns on the time invested,
and will continue
to return
"interest"
for as long as you write (and people use your) software!
It's impossible to "over-state" how vital writing tests first is to both your personal effectiveness and long-term sanity. Thankfully, by the end of this chapter, you will see how easy it is.
Build a fully functional "Todo List" Application!
Along the way we will cover:
- Building an App using a pre-made CSS Styles/Framework!
- The Document Object Model (DOM) + JSDOM
- Browser Routing/Navigation
- Local Storage for Offline Support
- Keyboard event listeners for rapid todo list creation and editing!
We will be abstracting all "architecture" related ("generic") code into a "mini frontend framework" called "elmish". (elmish is inspired by Elm but only meant for educational purposes!)
The journey to creating elmish is captured in
elmish.md
and fully documented code is in elmish.js
.
This means our Todo List App can be as concise
and "declarative" as possible.
If you are unfamiliar with Todo lists, simply put:
they are a way of keeping a list of the tasks that need to be done.
see: https://en.wikipedia.org/wiki/Time_management#Setting_priorities_and_goals
Todo Lists or "Checklists" are the best way of tracking tasks.
Atul Gawande wrote a superb book on this subject:
https://www.amazon.com/Checklist-Manifesto-How-Things-Right/dp/0312430000
Or if you don't have time to read,
watch: https://www.youtube.com/results?search_query=checklist+manifesto
If you have not come across TodoMVC before,
it's a website that showcases various "frontend" frameworks
using a common user interface (UI): a Todo List Application.
We highly recommend checking out the following links:
- Website: https://todomvc.com
- GitHub project: https://github.com/tastejs/todomvc
For our purposes we will simply be re-using the TodoMVC CSS
to make our TEA Todo List look good
(not have to "worry" about styles so we can focus on functionality).
All the JavaScript code will be written "from scratch"
to ensure that everything is clear.
This tutorial is for anyone/everyone who wants to develop their "core" JavaScript skills (without using a framework/library) while building a "real world" (fully functional) Todo List Application.
As always, if you get "stuck", please open an issue: https://github.com/dwyl/javascript-todo-list-tutorial/issues by opening a question you help everyone learn more effectively!
Most beginners with basic JavaScript and HTML knowledge should be able to follow this example without any prior experience. The code is commented and the most "complex" function is an event listener. With that said, if you feel "stuck" at any point, please consult the recommend reading (and Google) and if you cannot find an answer, please open an issue!
- Test Driven Developement: https://github.com/dwyl/learn-tdd
- Tape-specific syntax: https://github.com/dwyl/learn-tape
- Elm Architecture: https://github.com/dwyl/learn-elm-architecture-in-javascript
Start by cloning this repository to your localhost
so that you can follow the example/tutorial offline:
git clone https://github.com/dwyl/javascript-todo-list-tutorial.git
Install the devDependencies
so you can run the tests:
cd javascript-todo-list-tutorial && npm install
Now you have everything you need to build a Todo List from scratch!
In order to simplify the code for our Todo List App,
we abstracted much of the "generic" code
into a "front-end micro framework" called Elm
(ish).
The functions & functionality of Elm
(ish) should be familiar to you
so you should be able to build the Todo List using the Elm
(ish)
helper functions e.g: mount
, div
, input
and route
.
You can opt to either:
a) read the Elm
(ish) docs/tutorial
elmish.md
before
building the Todo List App -
this will give you both TDD practice
and a deeper understanding of building a micro framework.
i.e. "prospective learning"
b) refer the Elm
(ish) docs/tutorial
elmish.md
while
building the Todo List App when you "need to know"
how one of the helper functions works. i.e. "contextual learning"
c) only consult the Elm
(ish) docs/tutorial
elmish.md
if
you are "stuck" while
building the Todo List App.
i.e. "debug learning"
The choice is yours; there is no "right" way to learn.
Before diving into building the Todo List App, we need to consider how we are going to test it. By ensuring that we follow TDD from the start of an App, we will have "no surprises" and avoid having to "correct" any "bad habits".
We will be using Tape and JSDOM for testing
both our functions and the final application.
If you are new
to either of these tools,
please see:
github.com/dwyl/learn-tape
and
front-end-with-tape.md
We will be using JSDOC for documentation. Please see our tutorial if this is new to you.
In your editor/terminal create the following files:
test/todo-app.test.js
lib/todo-app.js
index.html
These file names should be self-explanatory, but if unclear,
todo-app.test.js
is where we will write the tests for our
Todo List App.
todo-app.js
is where all the JSDOCs and functions
for our Todo List App will be written.
In order to run our test(s), we need some "setup" code that "requires" the libraries/files so we can execute the functions.
In the test/todo-app.test.js
file, type the following code:
const test = require('tape'); // https://github.com/dwyl/learn-tape
const fs = require('fs'); // to read html files (see below)
const path = require('path'); // so we can open files cross-platform
const html = fs.readFileSync(path.resolve(__dirname, '../index.html'));
require('jsdom-global')(html); // https://github.com/rstacruz/jsdom-global
const app = require('../lib/todo-app.js'); // functions to test
const id = 'test-app'; // all tests use 'test-app' as root element
Most of this code should be familiar to you if you have followed previous tutorials. If anything is unclear please revisit https://github.com/dwyl/learn-tape and front-end-with-tape.md
If you attempt to run the test file: node test/todo-app.test.js
you should see no output.
(this is expected as we haven't written any tests yet!)
The model
for our Todo List App is boringly simple.
All we need is an Object
with a
todos
key which has an Array of Objects as it's value:
{
todos: [
{ id: 1, title: "Learn Elm Architecture", done: true },
{ id: 2, title: "Build Todo List App", done: false },
{ id: 3, title: "Win the Internet!", done: false }
]
}
todos
is an Array
of Objects
and each Todo (Array) item
has 3 keys:
id
: the index in the list.title
: the title/description of the todo item.done
: aboolean
indicating if the item is complete or still "todo".
The TodoMVC Specification requires us to display a
counter
of the items in the Todo list: https://github.com/tastejs/todomvc/blob/master/app-spec.md#counter
In order to display the count
of items in the Todo list,
we could store 3 values in the model:
total_items
- the total number of items, in this case 3.completed_items
- the number of completed items. in this case 1.incomplete_items
- the number of items still to be done; 2.
Each time a new item
is added to the list
we would need to update
both the total_items
and the incomplete_items
values in the model
.
And each time an item
gets checked off as "done",
we would need to update both the incomplete_items
and the completed_items
.
This is unnecessary effort we can avoid.
We can simply compute these values based on the data in the todos
Array
and display them for the user without storing any additional data.
Instead of storing any additional data for a counter
in the model
(the count of active and completed Todo items),
we will compute the count and display the count at "runtime".
We don't need to store any additional data in the model
.
This may use a few CPU cycles computing the count
each time the view is rendered but that's "OK"!
Even on an ancient Android device
this will only take a millisecond to compute and
won't "slow down" the app or affect UX.
See below for how the three counts are computed.
e.g: in the model above there are 3 todo items in the todos
Array;
2 items which are "active" (done=false
)
and 1 which is "done" (done=true
).
Given that the model
is "just data"
(
it has no "methods" because Elm
(ish) is
"Functional"
not
"Object Oriented"
),
there is no functionality to test.
We are merely going to test for the "shape" of the data.
In the test/todo-app.test.js
file, append following test code:
test('todo `model` (Object) has desired keys', function (t) {
const keys = Object.keys(app.model);
t.deepEqual(keys, ['todos', 'hash'], "`todos` and `hash` keys are present.");
t.true(Array.isArray(app.model.todos), "model.todos is an Array")
t.end();
});
If you run this test in your terminal:
node test/todo-app.test.js
You should see both assertions fail:
Write the minimum code required to pass this test in todo-app.js
.
e.g:
/**
* initial_model is a simple JavaScript Object with two keys and no methods.
* it is used both as the "initial" model when mounting the Todo List App
* and as the "reset" state when all todos are deleted at once.
*/
var initial_model = {
todos: [], // empty array which we will fill shortly
hash: "#/" // the hash in the url (for routing)
}
/* module.exports is needed to run the functions using Node.js for testing! */
/* istanbul ignore next */
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
model: initial_model
}
}
Once you save the todo-app.js
file and re-run the tests.
node test/todo-app.test.js
You should expect to see both assertions passing:
We're off to a great start! Let's tackle some actual functionality next!
The update
function is the
"brain"
of the App.
The JSDOC
for our update
function is:
/**
* `update` transforms the `model` based on the `action`.
* @param {String} action - the desired action to perform on the model.
* @param {Object} model - the App's data ("state").
* @return {Object} new_model - the transformed model.
*/
As with the update
in our counter
example
the function body is a switch
statement
that "decides" how to handle a request based on the action
(also known as the "message").
Given that we know that our update
function "skeleton"
will be a switch
statement
(because that is the "TEA" pattern)
a good test to start with is the default case
.
Append the following test code in test/todo-app.test.js
:
test('todo `update` default case should return model unmodified', function (t) {
const model = JSON.parse(JSON.stringify(app.model));
const unmodified_model = app.update('UNKNOWN_ACTION', model);
t.deepEqual(model, unmodified_model, "model returned unmodified");
t.end();
});
If you run this test in your terminal:
node test/todo-app.test.js
You should see the assertion fail:
Write the minimum code necessary to pass the test.
Yes, we could just write:
function update (action, model) { return model; }
And that would make the test pass.
But, in light of the fact that we know the update
function body will contain a switch
statement,
make the test pass by returning the model
unmodified in the default
case.
e.g:
/**
* `update` transforms the `model` based on the `action`.
* @param {String} action - the desired action to perform on the model.
* @param {Object} model - the App's (current) model (or "state").
* @return {Object} new_model - the transformed model.
*/
function update(action, model) {
switch (action) { // action (String) determines which case
default: // if action unrecognised or undefined,
return model; // return model unmodified
} // default? https://softwareengineering.stackexchange.com/a/201786/211301
}
When you re-run the test(s) in your terminal:
node test/todo-app.test.js
You should see this assertion pass:
Now that we have a passing test
for the default case
in our update
function,
we can move on to
thinking about the first (and most fundamental) piece
of functionality in the Todo List App: Adding an item to the list.
This is both the first "feature" a "user" will encounter and
by far the most used feature of a Todo List.
(by definition people add more items to their list than they finish,
to finish everything we would have to
live forever!)
Adding a new todo item's text should
append the todo item Object
to the model.todos
Array.
Such that the model
is transformed (data is added) in the following way:
BEFORE:
{
todos: [],
hash: "#/"
}
AFTER:
{
todos: [
{id: 1, "Add Todo List Item", done: false }
],
hash: "#/"
}
While considering the "Acceptance Criteria"
for adding an item to the Todo List,
we notice that our update
JSDOC
and corresponding function "signature" (defined above) as:
/**
* `update` transforms the `model` based on the `action`.
* @param {String} action - the desired action to perform on the model.
* @param {Object} model - the App's (current) model (or "state").
* @return {Object} updated_model - the transformed model.
*/
function update(action, model) {
switch (action) { // action (String) determines which case
default: // if action unrecognised or undefined,
return model; // return model unmodified
} // default? https://softwareengineering.stackexchange.com/a/201786/211301
}
does not have a parameter for passing in the Todo List item Text (title
),
i.e. how do we add "data" to the model
...?
That's "Oh kay"! (don't panic!)
If we try
to think about implementation up-front,
we would invariably be "over-thinking" things
and get "stuck" in the
"analysis paralysis"
of
"waterfall"
As you are about to see, we can easily change the function signature, in the next test without affecting our exiting (passing) test!
As you practice "DDD" & "TDD" you will begin to appreciate and even embrace the mental agility that comes from not "over-thinking" things.
Whenever you encounter a "New Requirement"
(or realise that you didn't fully consider the original requirements),
you know that your suite of tests has
"
got your
back
".
You can "refactor" a function's implementation to your heart's content,
safe in the knowledge that all your existing tests still pass.
i.e. the rest of the app "still works" exactly as expected.
We don't want to "mess with" either of the other two (existing) parameters,
both action
and model
have clearly defined purposes,
but we need a way to pass "data" into the update
function!
With that in mind, let's amend the update
JSDOC
comment
and function signature to:
/**
* `update` transforms the `model` based on the `action`.
* @param {String} action - the desired action to perform on the model.
* @param {Object} model - the App's (current) model (or "state").
* @param {String} data - data we want to "apply" to the item. e.g: item Title.
* @return {Object} updated_model - the transformed model.
*/
function update(action, model, data) {
switch (action) { // action (String) determines which case
default: // if action unrecognised or undefined,
return model; // return model unmodified
} // default? https://softwareengineering.stackexchange.com/a/201786/211301
}
Without making any other changes, re-run the tests:
node test/todo-app.test.js
Congratulations! You just extended a function (signature) without affecting any existing tests.
Append the following test code to your test/todo-app.test.js
file:
test('`ADD` a new todo item to model.todos Array via `update`', function (t) {
const model = JSON.parse(JSON.stringify(app.model)); // initial state
t.equal(model.todos.length, 0, "initial model.todos.length is 0");
const updated_model = app.update('ADD', model, "Add Todo List Item");
const expected = { id: 1, title: "Add Todo List Item", done: false };
t.equal(updated_model.todos.length, 1, "updated_model.todos.length is 1");
t.deepEqual(updated_model.todos[0], expected, "Todo list item added.");
t.end();
});
If you run this test in your terminal:
node test/todo-app.test.js
You should see the assertion fail:
With the above test as your "guide", write the bare minimum code necessary to make all assertions pass.
Sample implementation:
/**
* `update` transforms the `model` based on the `action`.
* @param {String} action - the desired action to perform on the model.
* @param {Object} model - the App's (current) model (or "state").
* @param {String} data - the data we want to "apply" to the item.
* @return {Object} updated_model - the transformed model.
*/
function update(action, model, data) {
var new_model = JSON.parse(JSON.stringify(model)) // "clone" the model
switch(action) { // and an action (String) runs a switch
case 'ADD':
new_model.todos.push({
id: model.todos.length + 1,
title: data,
done: false
});
break;
default: // if action unrecognised or undefined,
return model; // return model unmodified
} // see: https://softwareengineering.stackexchange.com/a/201786/211301
return new_model;
}
the case 'ADD'
is the relevant code.
Was your implementation similar...?
If you were able to make it simpler, please share!
Once you have the test(s) passing e.g:
Let's move on to the next functionality!
Checking off a todo item involves changing the value of the done
property
from false
to true
. e.g:
FROM:
{
todos: [
{id: 1, "Toggle a todo list item", done: false }
]
}
TO:
{
todos: [
{id: 1, "Toggle a todo list item", done: true }
]
}
Given that we have already defined our update
function above,
we can dive straight into writing a test:
Append the following test code to your test/todo-app.test.js
file:
test('`TOGGLE` a todo item from done=false to done=true', function (t) {
const model = JSON.parse(JSON.stringify(app.model)); // initial state
const model_with_todo = app.update('ADD', model, "Toggle a todo list item");
const item = model_with_todo.todos[0];
const model_todo_done = app.update('TOGGLE', model_with_todo, item.id);
const expected = { id: 1, title: "Toggle a todo list item", done: true };
t.deepEqual(model_todo_done.todos[0], expected, "Todo list item Toggled.");
t.end();
});
execute the test:
node test/todo-app.test.js
You should see something similar to the following:
With the above test as your "guide",
write the minimum code necessary to make the test pass.
(ensure that you continue to make a "copy" of the model
rather than "mutate" it)
Once you make it pass you should see:
Try to make the test pass alone (or with your pairing partner). If you get "stuck" see:
todo-app.js
Yes, you guessed it!
Choosing to name the action
as "TOGGLE
"
is precisely because we don't need
to have a separate function
to "undo" an item if it has been "checked off".
Append the following test code to your test/todo-app.test.js
file:
test('`TOGGLE` (undo) a todo item from done=true to done=false', function (t) {
const model = JSON.parse(JSON.stringify(app.model)); // initial state
const model_with_todo = app.update('ADD', model, "Toggle a todo list item");
const item = model_with_todo.todos[0];
const model_todo_done = app.update('TOGGLE', model_with_todo, item.id);
const expected = { id: 1, title: "Toggle a todo list item", done: true };
t.deepEqual(model_todo_done.todos[0], expected, "Toggled done=false >> true");
// add another item before "undoing" the original one:
const model_second_item = app.update('ADD', model_todo_done, "Another todo");
t.equal(model_second_item.todos.length, 2, "there are TWO todo items");
// Toggle the original item such that: done=true >> done=false
const model_todo_undone = app.update('TOGGLE', model_second_item, item.id);
const undone = { id: 1, title: "Toggle a todo list item", done: false };
t.deepEqual(model_todo_undone.todos[0],undone, "Todo item Toggled > undone!");
t.end();
});
You should not need to modify any of the code in the update
function.
The above test should just pass based on the code you wrote above.
If it does not, then revise your implementation
of the TOGGLE case
in update
until all tests pass:
It won't have "escaped" you that so far we have not written any code that a user can actually interact with.
So far we have successfully added two case
blocks in the switch
statement
of our update
function. We now have the two basic functions required
to both ADD
a new todo list item to the model.todos
Array
and check-off a todo list item as "done" using the TOGGLE action
.
This is "enough" functionality to start using the todo list (ourselves)
and UX-testing it with prospective "users".
If you followed through the "Elm(ish)" tutorial
elmish.md
you will have seen that we created a sample view
in the last few tests
to "exercise" the DOM element creation functions.
This means that we already know how to build a view
for our Todo List App!
We "just" need to adapt the view
we made in Elm
(ish) to display
the data in our model
.
Let's return to the sample model
from above:
{
todos: [
{ id: 1, title: "Learn Elm Architecture", done: true },
{ id: 2, title: "Build Todo List App", done: false },
{ id: 3, title: "Win the Internet!", done: false }
],
hash: '#/' // the "route" to display
}
The model contains three items in the todos
Array.
The first is complete (done=true
)
whereas the second and third items are still "todo" (done=false
).
This is what this model
looks like in the "VanillaJS"
TodoMVC:
Our quest in the next "pomodoro" is to re-create this
using the DOM functions we created in Elm
(ish)!
For now, ignore the <footer>
(below the Todo List)
and just focus on rendering the list itself.
In your web browser, open Developer Tools and inspect the HTML for the Todo list: https://todomvc.com/examples/vanillajs/
This is the HTML copied directly from the browser:
<section class="main" style="display: block;">
<input class="toggle-all" type="checkbox">
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<li data-id="1533501855500" class="completed">
<div class="view">
<input class="toggle" type="checkbox">
<label>Learn Elm Architecture</label>
<button class="destroy"></button>
</div>
</li>
<li data-id="1533501861171" class="">
<div class="view">
<input class="toggle" type="checkbox">
<label>Build Todo List App</label>
<button class="destroy"></button>
</div>
</li>
<li data-id="1533501867123" class="">
<div class="view"><input class="toggle" type="checkbox">
<label>Win the Internet!</label>
<button class="destroy"></button>
</div>
</li>
</ul>
</section>
Note: there is "redundant" markup in this HTML in the form of a
<div>
inside the<li>
, for now we are just replicating the HTML "faithfully", we can "prune" it later.
From this HTMl we can write our "Technical Acceptance Criteria":
- Todo List items should be displayed as list items
<li>
in an unordered list<ul>
. - Each Todo List item
<li>
should contain a<div>
with aclass="view"
which "wraps":-
<input class="toggle" type="checkbox">
- the "checkbox" that people can "Toggle" to change the "state" of the Todo item from "active" to "done" (which updates the model From:model.todos[id].done=false
To:model.todos[id].done=true
) -
<label>
- the text content ("title") of the todo list item -
<button class="destroy">
- the button the person can click/tap todelete
a Todo item.
-
Given the model
(above),
- There is a
<ul class="todo-list">
with 3<li>
(list items) rendered in theview
. - The first
<li>
has an<input type="checkbox">
which is checked (done=true
) - The remaining
<li>'s
have<input type="checkbox">
that are unchecked (done=false
)
Let's "tackle" the first assertion first:
It's always a good idea to "break apart" a test into smaller tests
because it means we will write smaller
(and thus more maintainable) "composable" functions.
With that in mind, let's add the following test to test/todo-app.test.js
:
test.only('render_item HTML for a single Todo Item', function (t) { const model = { todos: [ { id: 1, title: "Learn Elm Architecture", done: true }, ], hash: '#/' // the "route" to display }; // render the ONE todo list item: document.getElementById(id).appendChild(app.render_item(model.todos[0])) const done = document.querySelectorAll('.completed')[0].textContent; t.equal(done, 'Learn Elm Architecture', 'Done: Learn "TEA"'); const checked = document.querySelectorAll('input')[0].checked; t.equal(checked, true, 'Done: ' + model.todos[0].title + " is done=true"); elmish.empty(document.getElementById(id)); // clear DOM ready for next test t.end(); });