Intermediate project · Medium
To-do list that remembers
You build one HTML page with its own CSS and JavaScript that lets you add tasks, tick them off, delete them and filter between all, active and done. Everything is saved in the browser's own storage, so closing the tab and coming back leaves the list exactly as it was. No framework and no build step: just the three files and the browser's DOM API.
- Languages
- HTML & CSSJavaScript
- Size
- Medium: a few sessions
- Where to build it
- Build this one on your own computer, in an index.html with its own CSS and JS. The HTML and CSS playground here cannot run it: its preview frame has no origin of its own, so localStorage throws instead of saving, and nothing in the playground is kept when you leave the page. Any editor and a browser opening the file directly is enough; there is nothing to install.
What you will practise
- keeping state in one place and rendering the DOM from it
- event handling, including one listener for a whole list
- reading and writing localStorage, and surviving when it fails
- serialising to JSON and validating what comes back
- keyboard accessibility and focus management
- avoiding HTML injection by setting text rather than markup
Requirements
The project is done when every one of these is true.
- There is a single source of truth: an array of task objects in JavaScript, and every visible change goes through it rather than editing the DOM directly.
- You can add a task, toggle it between done and not done, and delete it, and each of those updates both the array and what is on screen.
- Filter controls switch between all, active and done, the current filter is visibly marked, and switching filters never changes the tasks themselves.
- The whole list is saved after every change and reloaded on start, so a full page refresh shows the same tasks in the same order with the same done states.
- Submitting an empty or whitespace-only task adds nothing and leaves the input focused with a visible message.
- Task text is put into the page as text, never as markup, so a task called <b>hi</b> shows those exact characters instead of turning bold.
- When storage is unavailable or holds something that is not a valid task list, the app starts with an empty list and a quiet notice rather than throwing and rendering nothing.
- The whole app is usable by keyboard alone: adding with Enter, reaching every toggle and delete control with Tab, and a visible focus style throughout.
- An empty list shows a deliberate empty state, and so does a filter that currently matches nothing.
Milestones
A sensible order to build it in, so something works at every step.
Render a hard-coded array
Write the task array by hand in the JavaScript and get a render function that draws it from scratch every time it is called. No adding or saving yet.
Add and delete
Wire the form to push onto the array and re-render, and put one click listener on the list container that works out which task a delete button belongs to.
Toggle done, and style it
Add the done flag and a checkbox per row, then style the done state so the difference is not colour alone.
Add the filters
Keep the current filter in a variable next to the array, filter inside the render function, and mark the active control so the state is visible.
Save and load
Write save and load functions that go through JSON, call save after every change, and load once at startup inside a try so a failure cannot stop the app.
Validate what comes back
Do not trust stored data: check it is an array and that each item has the fields and types you expect, dropping anything that does not, before it reaches the render function.
Do the accessibility and empty-state pass
Tab through everything, label every control, add the empty states, and confirm the messages are announced rather than only styled.
Hints
Open one only when you are stuck. Each gives a little more away.
Show hint 1Hint 1
Re-rendering the whole list from the array is slower than surgically patching the DOM and far easier to get right. Start there; optimise only if you can see it being slow.
Show hint 2Hint 2
One listener on the list container beats one listener per row, because rows come and go. Put the task id in a data attribute on the row and read it from the event target.
Show hint 3Hint 3
innerHTML with user text is how a to-do app becomes an HTML injection bug. Create the element and set its text content instead.
Show hint 4Hint 4
Storage holds strings, not objects, so JSON.stringify on the way out and JSON.parse on the way in. Parsing can throw on anything that is not valid JSON, so it belongs inside a try.
Show hint 5Hint 5
Every read and write to storage can throw, not just return nothing, and it can be blocked in a private window or by browser settings. Wrap both, and make the app work with an empty list when they fail.
Show hint 6Hint 6
Give each task a stable id when it is created, not its position in the array. Positions change when you delete, and then the wrong task gets toggled.
How to test it
Run these checks yourself, or turn them into automated tests once you know how.
- Add three tasks, reload the page, and confirm all three are still there in the same order. Then delete the first, reload again, and confirm it is gone while the other two are unchanged.
- Mark the middle task done, reload, and confirm it is still marked done and the other two are not.
- Type only spaces and submit: nothing should be added, the list should be unchanged, and a message should appear.
- Add a task whose text is <b>hi</b>. The row must show those exact characters, including the angle brackets, and no bold text anywhere.
- Clear the app's storage in your browser's developer tools and reload: you should see the empty state, not a blank page or a console error.
- Put the string not-json into the storage key by hand and reload: the app should start empty with a notice, not throw.
- Switch to the done filter with no completed tasks and confirm you get the filter-specific empty state rather than a blank area.
- Unplug the mouse, or just do not touch it: add a task with Enter, reach its checkbox and delete button with Tab, and confirm focus is always visible.
Stretch goals
- Add editing in place: double-click a task to edit the text, with Escape to cancel and Enter to save.
- Add a clear-completed button and a count of remaining tasks that updates live.
- Add drag-and-drop reordering, keeping a keyboard alternative such as move-up and move-down buttons.
- Version your stored data and write a small migration so an old saved shape still loads.
- Add an export and import button that downloads and reads back a JSON file.