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://dwyl.github.io/javascript-todo-list-tutorial/
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.
Create a new
directory e.g: /todo-app
So that you can build the Todo List from scratch!
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/main/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.