A persistent to-do app in plain JavaScript is the smallest program that does something a beginner actually cares about: it takes input, changes state, survives a restart, and tells you what happened. It has four files, no dependencies and no build step, and you can type the commands by hand.
What does the finished app do?
Four commands, each one small enough to read:
add <title>puts a new to-do at the end of the list.done <id>flips a to-do between finished and unfinished.remove <id>deletes a to-do.listprints everything that is still there.
The word that makes it a program rather than a demonstration is persistent. The list is written to a file on disk after every change, so the second run of the app starts where the first one finished. Delete the file and you are back to an empty list; there is no hidden state anywhere else.
How is the work divided between the files?
todo-app/
data/
todos.json created on the first run, rewritten after every change
src/
todos.mjs the rules about to-dos, no imports at all
storage.mjs reads and writes the file, no imports at all
cli.mjs turns the typed words into a command, no imports at all
index.mjs imports the other three and runs them
Every arrow points the same way, so the dependency picture stays flat:
index.mjs ---> todos.mjs
---> storage.mjs
---> cli.mjs
The suffixes are the ones Node expects for ES modules without extra configuration. If you would rather use plain .js names, add a package.json with the type set to module, and the same files load as modules either way.
Where do the to-do rules live?
All four rules live in one file, and each one takes a list and returns a new list. Nothing is changed in place, nothing is printed, and nothing is read from disk. That is what makes the rules easy to test and easy to reuse.
export function addTodo(todos, title) {
const clean = String(title).trim();
if (!clean) throw new Error('a to-do needs a title');
const id = todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1;
return [...todos, { id, title: clean, done: false }];
}
export function toggleTodo(todos, id) {
if (!todos.some((todo) => todo.id === id)) throw new Error('no to-do with id ' + id);
return todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo));
}
export function removeTodo(todos, id) {
if (!todos.some((todo) => todo.id === id)) throw new Error('no to-do with id ' + id);
return todos.filter((todo) => todo.id !== id);
}
export function formatList(todos) {
if (todos.length === 0) return 'Nothing on the list.';
return todos
.map((todo) => (todo.done ? '[x]' : '[ ]') + ' ' + todo.id + '. ' + todo.title)
.join('\n');
}
Two decisions are worth pointing out. The next id is derived from the highest id already in the list, so ids stay unique without a counter that has to be saved separately. And each rule throws when it is asked to do something impossible — an empty title, an id that is not on the list — so the caller gets one clear error instead of a broken list.
How does the list survive between runs?
The storage file is the only place that touches the disk, and it hides itself behind two methods: one to load the list and one to save it. The interesting line is the first one, where a missing file is treated as an empty list rather than as a crash.
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
export function createFileStorage(filePath) {
return {
async load() {
try {
return JSON.parse(await readFile(filePath, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return [];
throw error;
}
},
async save(todos) {
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(todos, null, 2) + '\n', 'utf8');
},
};
}
The error code ENOENT means the file was not there, which on the very first run is expected rather than exceptional. Any other failure — a folder where a file should be, a file full of broken text — is passed on so the caller can report it. Writing the file creates the folder first, so the app works on a machine that has never run it before.
export function parseArgs(argv) {
const [command, ...rest] = argv;
return { command: command || 'help', args: rest };
}
export function usage() {
return [
'usage: node src/index.mjs <command> [value]',
' add <title> add a to-do',
' done <id> mark a to-do as finished',
' remove <id> delete a to-do',
' list show every to-do',
].join('\n');
}
Because the title is joined back together from the arguments, a to-do with spaces in it survives the trip through the shell.
Where does everything meet?
The entry file imports the three modules, works out which command was asked for, loads the list, applies the matching rule, saves the result and prints one line of confirmation. Every failure ends up in a single catch block at the very bottom.
import { fileURLToPath } from 'node:url';
import { createFileStorage } from './storage.mjs';
import { addTodo, toggleTodo, removeTodo, formatList } from './todos.mjs';
import { parseArgs, usage } from './cli.mjs';
const dataFile = fileURLToPath(new URL('../data/todos.json', import.meta.url));
const storage = createFileStorage(dataFile);
const { command, args } = parseArgs(process.argv.slice(2));
async function main() {
const todos = await storage.load();
if (command === 'add') {
const updated = addTodo(todos, args.join(' '));
await storage.save(updated);
console.log('added: ' + updated[updated.length - 1].title);
return;
}
if (command === 'done') {
const id = Number(args[0]);
const updated = toggleTodo(todos, id);
await storage.save(updated);
console.log('finished: ' + updated.find((todo) => todo.id === id).title);
return;
}
if (command === 'remove') {
const updated = removeTodo(todos, Number(args[0]));
await storage.save(updated);
console.log('removed id ' + args[0]);
return;
}
if (command === 'list') {
console.log(formatList(todos));
return;
}
console.log(usage());
}
main().catch((error) => {
console.error('error: ' + error.message);
process.exitCode = 1;
});
Read the file as a list of small branches. Each branch does the same three things: apply a rule from the rules file, save the result, print a confirmation. The catch block at the bottom is the only place that decides a failure is fatal, and it sets a failing exit status so a script that runs this app can tell whether it worked.
What does it look like when it runs?
Create the folder and the four files, then run the commands one after another:
$ node src/index.mjs add "Finish the async chapter"
added: Finish the async chapter
$ node src/index.mjs add "Write the fetch examples"
added: Write the fetch examples
$ node src/index.mjs list
[ ] 1. Finish the async chapter
[ ] 2. Write the fetch examples
$ node src/index.mjs done 1
finished: Finish the async chapter
$ node src/index.mjs list
[x] 1. Finish the async chapter
[ ] 2. Write the fetch examples
$ node src/index.mjs remove 2
removed id 2
$ node src/index.mjs list
[x] 1. Finish the async chapter
Each run is a separate process, and the list is still there between them, because the state lives in the file rather than in memory. That file now holds the same list in a form you can read:
[
{
"id": 1,
"title": "Finish the async chapter",
"done": true
}
]
Asking for something that is not there produces one line and a failing exit status, rather than a stack trace:
$ node src/index.mjs done 9
error: no to-do with id 9
Two smaller cases behave the same way. On a machine that has never run the app the file does not exist yet, and the list command answers with an empty list rather than with an error:
$ node src/index.mjs list
Nothing on the list.
A command that does not exist prints the help text, which is usually friendlier than a complaint about an unknown option:
$ node src/index.mjs frobnicate
usage: node src/index.mjs <command> [value]
add <title> add a to-do
done <id> mark a to-do as finished
remove <id> delete a to-do
list show every to-do
The exit status follows the same rule as the message. A successful run leaves it at zero and a failed one sets it to one, so a script that runs the app can tell the two apart without reading the output.
What the capstone actually shows
- The rules are ordinary functions with no imports, so they can be tested in isolation and reused anywhere.
- One file owns the outside world, and the entry file decides what the outside world really is.
- Failures are raised where they happen and handled in exactly one place, which is why the messages are short and honest.
- State that must survive a restart belongs in storage, not in a variable that a fresh process will forget.
- Small programs do not need a framework; they need a layout, and the layout is a handful of decisions you make once.
Try it yourself: Build the app, then add a fourth command called clear that empties the list, and make sure the rules file gains a rule rather than the entry file growing a new branch. Next, change the storage module so the file is written with no indentation, and confirm that the list still reloads correctly. Finally, break the id rule so every new to-do gets the id 1, run the app twice, and look at what the list file contains — that is the kind of bug the tests from the previous lesson would have caught before you typed a single command.