Skip to main content

Step-by-step tutorial

JavaScript Tutorial: Build a Filterable Task List

Build a small application with one state model, pure data transformations, event-driven updates, safe DOM rendering, and persistent storage.

JavaScriptbeginner4 min readProgramming languages

Learning overview

Estimated time
4 minutes
Difficulty
Beginner
Prerequisites
No prior experience required
Learning outcome
Explain javascript using a clear mental model · Apply javascript in practical JavaScript work
Last updated
July 18, 2026

What you will build and learn

The application can add, complete, filter, and remove tasks. State lives in one array of objects. Every user action changes state and then calls one render function, keeping data logic separate from DOM output.

  • Represent application state with arrays and objects
  • Update state without hidden mutation
  • Render with safe DOM APIs
  • Persist and restore JSON data

Step 1: Define the state model

Each task needs a stable ID, text, and completion flag. Const prevents the array binding from being reassigned accidentally; replace the binding with let only if your update strategy assigns a new array.

state.js
let tasks = [
  { id: crypto.randomUUID(), text: "Review array methods", completed: false },
];

let activeFilter = "all";

Step 2: Write data transformations

Keep state operations independent from the DOM. Map returns an array with one task changed, filter removes a matching task, and another filter derives the visible set.

tasks.js
function toggleTask(items, id) {
  return items.map((task) =>
    task.id === id ? { ...task, completed: !task.completed } : task
  );
}

function removeTask(items, id) {
  return items.filter((task) => task.id !== id);
}

function visibleTasks(items, filter) {
  if (filter === "active") return items.filter((task) => !task.completed);
  if (filter === "completed") return items.filter((task) => task.completed);
  return items;
}

Step 3: Render state safely

Clear the list, create elements, set text through textContent, and attach task IDs through dataset. This avoids parsing user text as HTML and gives event handlers a stable identifier.

render.js
const list = document.querySelector("#task-list");

function render() {
  list.replaceChildren();
  for (const task of visibleTasks(tasks, activeFilter)) {
    const item = document.createElement("li");
    item.dataset.taskId = task.id;

    const button = document.createElement("button");
    button.type = "button";
    button.textContent = task.completed ? `Undo ${task.text}` : `Complete ${task.text}`;
    button.dataset.action = "toggle";

    item.append(button);
    list.append(item);
  }
}

Step 4: Connect user events

The form handler validates trimmed input and creates a task. Event delegation on the list handles buttons added during future renders without registering a separate listener for every item.

events.js
document.querySelector("#task-form").addEventListener("submit", (event) => {
  event.preventDefault();
  const input = event.currentTarget.elements.task;
  const text = input.value.trim();
  if (!text) return;

  tasks = [...tasks, { id: crypto.randomUUID(), text, completed: false }];
  input.value = "";
  save();
  render();
});

list.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-action]");
  if (!button) return;
  tasks = toggleTask(tasks, button.closest("li").dataset.taskId);
  save();
  render();
});

Step 5: Persist and restore tasks

Local storage stores strings, so serialize the array as JSON. Parsing can fail when stored data is corrupted; recover to an empty list instead of preventing the application from starting.

storage.js
const storageKey = "avora.tasks.v1";

function save() {
  localStorage.setItem(storageKey, JSON.stringify(tasks));
}

function load() {
  try {
    const stored = JSON.parse(localStorage.getItem(storageKey) ?? "[]");
    tasks = Array.isArray(stored) ? stored : [];
  } catch {
    tasks = [];
  }
}

load();
render();

Step 6: Test behavior and edge cases

Test data functions independently, then test the complete user flow. Add an empty state, accessible status message, delete action, and clear-completed action only after the core behavior is reliable.

  • Whitespace-only tasks are rejected
  • Repeated task text still receives distinct IDs
  • Filters update after completion changes
  • Special characters render as text
  • Corrupt saved data does not crash startup
  • All actions work using only a keyboard

Frequently asked questions

Why keep JavaScript state separate from the DOM?

A separate state model makes behavior predictable, testable, and easier to persist. The DOM becomes an output of state instead of a second competing data store.

Why use textContent instead of innerHTML?

textContent renders user input as text. innerHTML parses markup and can create cross-site scripting vulnerabilities when data is not strictly trusted and sanitized.

Is local storage suitable for sensitive data?

No. It is readable by scripts running on the origin and has no built-in expiration. Use it only for non-sensitive client-side preferences or small application data.

Topic graph

A taxonomy-generated path through this subject.

  1. JavaScript
  2. Programming languages
  3. javascript
  4. tutorial
  5. dom
  6. local-storage
  7. What Is JavaScript in Programming?
  8. Learn JavaScript: From Fundamentals to Web Applications
  9. Python Tutorial: Build a Command-Line Expense Tracker

Recommended automatically from shared technologies, topics, intent, and difficulty.

Hand-picked companion pages that deepen this topic.

Your next steps

Continue learning

  1. 1glossaryWhat Is JavaScript in Programming?
  2. 2guidesLearn JavaScript: From Fundamentals to Web Applications
  3. 3guidesPython Tutorial: Build a Command-Line Expense Tracker
  4. 4guidesJava Tutorial: Build a Student Grade Calculator