Jeg lærte JavaScript ved at skrive en to do app igen og igen
Det korte svar er at en to do app i JavaScript er: en liste i hukommelsen, en render-funktion og et par event listeners. Men det længere svar er mere interessant.
Min første to do app var noget rod. Jeg kopierede tilfældige kodebider fra Stack Overflow, gemte ting halvt i DOM’en, halvt i variabler og forstod ikke hvorfor ting forsvandt, når jeg reloadede siden. Alligevel var det projektet, hvor JavaScript pludselig gav mening.
I den her artikel bygger vi en to do app i JavaScript, som både føles simpel og ligner rigtig kode: én state-variabel, én render-funktion, events der ændrer state og localStorage til at gemme data i browseren.
Hvad vores to do app faktisk skal kunne
Lad mig starte med målet, så vi ved, hvad vi sigter efter.
- Tilføje en opgave med et inputfelt + knap
- Se alle opgaver i en liste
- Markere en opgave som færdig (toggle)
- Slette en opgave
- Automatisk gemme det hele i
localStorage, så det er der efter reload
Vi bygger det render-first:
- Én array med opgaver (vores “state”)
- Hver gang state ændrer sig, kalder vi
render() render()rydder listen og bygger den igen ud fra state
Det virker måske lidt brutalt at “tegne alt igen”, men det gør koden meget lettere at forstå som begynder. Du skal ikke holde styr på 17 små DOM-opdateringer.
HTML-skelet og lidt CSS-hooks
Vi starter med noget helt simpelt HTML. Ingen frameworks, ingen magi.
<body>
<main class="todo-app">
<h1>Min To Do App</h1>
<form id="todo-form">
<input
id="todo-input"
type="text"
placeholder="Hvad skal du huske?"
autocomplete="off">
<button type="submit">Tilføj</button>
</form>
<ul id="todo-list"></ul>
</main>
</body>
Nogle få ting at bemærke:
<form>omkring input + knap, så Enter også tilføjer opgavenid="todo-input"ogid="todo-list"så vi nemt kan fange dem i JS<ul>til selve opgavelisten
Lidt minimal CSS (bare så vi har klassers navne klar):
.todo-app { max-width: 480px; margin: 40px auto; font-family: system-ui, sans-serif; }
#todo-form { display: flex; gap: 8px; }
#todo-input { flex: 1; padding: 8px; }
#todo-list { list-style: none; padding: 0; margin-top: 16px; }
.todo-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
.todo-text.done { text-decoration: line-through; color: #888; }
.todo-delete { margin-left: auto; }
Det her er nok til at vi kan se, hvad der foregår, mens vi leger med JavaScript.
Data-modellen: sådan gemmer vi en opgave i koden
Hvis du kun tager én ting med herfra, så lad det være den her: tænk i data før du tænker i DOM.
Vi beslutter os for, hvordan en opgave ser ud som JavaScript-objekt:
{
id: 'abc123', // unik id som string
text: 'Køb kaffe',
done: false // true når den er færdig
}
Og vores state bliver så en liste (array) af sådan nogle objekter:
let todos = [
{ id: '1', text: 'Byg en to do app', done: false },
{ id: '2', text: 'Drik kaffe', done: true }
];
Senere skal det her array både:
- bruges til at tegne opgaverne i DOM
- serialiseres (gøres til tekst) og gemmes i
localStorage
En lille hjælpefunktion til id’er
Vi skal bruge et simpelt id. Det behøver ikke være kryptosikkert. Bare unikt nok.
function createId() {
return Date.now().toString() + Math.random().toString(16).slice(2);
}
Det her er ikke smukt, men det virker. Og det gør det nemt at finde en opgave igen, når vi klikker på den i DOM’en.
DOM og render-funktionen: én kilde til sandhed
Nu binder vi state og DOM sammen. Vi laver en render()-funktion som:
- Rydder
<ul id="todo-list"> - Looper over
todos - Opretter et
<li>pr. opgave
const listEl = document.getElementById('todo-list');
function render() {
// 1. Ryd listen
listEl.innerHTML = '';
// 2. Loop over alle todos
todos.forEach(todo => {
const li = document.createElement('li');
li.className = 'todo-item';
li.dataset.id = todo.id; // gem id på DOM-elementet
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = todo.done;
checkbox.className = 'todo-toggle';
const span = document.createElement('span');
span.textContent = todo.text;
span.className = 'todo-text' + (todo.done ? ' done' : '');
const button = document.createElement('button');
button.textContent = 'Slet';
button.className = 'todo-delete';
li.appendChild(checkbox);
li.appendChild(span);
li.appendChild(button);
listEl.appendChild(li);
});
}
Når du opdaterer todos og kalder render(), vil UI’et altid matche din data. Ingen halvmærkelige mellemtilstande.
todos manuelt i forskellige funktioner. Lad state være sandheden, og lad DOM følge state via render().Events: tilføjelse, toggling og sletning
Nu skal brugeren kunne gøre noget. Vi starter med “tilføj”.
Tilføj opgave via form submit
const formEl = document.getElementById('todo-form');
const inputEl = document.getElementById('todo-input');
formEl.addEventListener('submit', function (event) {
event.preventDefault(); // stop side-reload
const text = inputEl.value.trim();
if (text === '') {
return; // ingen tomme opgaver
}
const newTodo = {
id: createId(),
text,
done: false
};
todos.push(newTodo);
saveTodos();
render();
inputEl.value = '';
inputEl.focus();
});
Her sker der fire vigtige ting:
- Vi trim’er input (fjerner mellemrum før/efter)
- Vi opdaterer kun state (
todos.push) - Vi kalder
saveTodos()(kommer om lidt) og sårender() - Vi nulstiller inputfeltet
Event delegation: håndter klik på listen ét sted
Vi kunne sætte en addEventListener på hver knap og checkbox. Men det bliver hurtigt tungt at holde styr på.
I stedet bruger vi event delegation: vi lytter på <ul> og tjekker, hvad der faktisk blev klikket på.
listEl.addEventListener('click', function (event) {
const target = event.target;
const li = target.closest('.todo-item');
if (!li) return;
const id = li.dataset.id;
if (target.classList.contains('todo-toggle')) {
toggleTodo(id);
}
if (target.classList.contains('todo-delete')) {
deleteTodo(id);
}
});
closest() går op gennem DOM-træet, til den finder en forælder med den ønskede klasse. Perfekt til at finde den rigtige <li>.
Funktioner til toggle og delete
function toggleTodo(id) {
todos = todos.map(todo => {
if (todo.id === id) {
return { ...todo, done: !todo.done };
}
return todo;
});
saveTodos();
render();
}
function deleteTodo(id) {
todos = todos.filter(todo => todo.id !== id);
saveTodos();
render();
}
Jeg bruger map og filter til at lave et nyt array i stedet for at mutere det gamle i stedet for at rode med splice og for-loops. Det er lettere at tænke over, når du skal debugge.
todos overhovedet og prøver i stedet kun at fjerne DOM-elementet. Det ser ud til at virke, indtil du reloader siden eller re-render noget andet.localStorage: gem dine opgaver i browseren
localStorage er som et lille nøgle-værdi lager i browseren. Men det kan kun gemme strings, ikke rigtige objekter og arrays.
Så vi gør det her:
- Når vi vil gemme:
JSON.stringify(todos)og læg det ilocalStorage - Når vi vil læse: hent strengen,
JSON.parseden til et array igen
Gem state
const STORAGE_KEY = 'my_todo_app_v1';
function saveTodos() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
Indlæs ved start
function loadTodos() {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) {
todos = [];
return;
}
try {
const parsed = JSON.parse(data);
if (Array.isArray(parsed)) {
todos = parsed;
} else {
todos = [];
}
} catch (e) {
console.error('Kunne ikke parse todo data', e);
todos = [];
}
}
loadTodos();
render();
Bemærk try/catch. Hvis localStorage en dag indeholder noget mærkeligt (fx fordi du har eksperimenteret i DevTools), så crasher appen ikke.
JSON.stringify og skriver et objekt direkte i localStorage. Så får du [object Object] som string og alt går galt, når du prøver at parse det.Edge cases: tom input, dubletter og whitespace
Hvis du vil have din app til at føles bare lidt gennemarbejdet, så håndter lige de her småting.
Tomt eller næsten tomt input
Vi har allerede .trim() i vores submit-handler. Du kan tilføje en lille fejltekst, hvis du vil:
if (text === '') {
// evt. vis en besked i DOM i stedet for bare at returnere
return;
}
Dubletter
Skal brugeren kunne skrive den samme opgave to gange? Det er egentlig et designvalg.
Hvis du vil undgå dubletter:
const exists = todos.some(todo => todo.text.toLowerCase() === text.toLowerCase());
if (exists) {
// vis en besked eller markér feltet
return;
}
Små visuelle detaljer
Det er også nu du kan tweake ting som:
- fokus automatisk på inputfeltet når siden loader
- autofokus tilbage på input efter tilføj
- lille fade-animation når noget slettes (pure CSS)
Det er ikke strengt nødvendigt, men det er sådan noget der gør, at projektet føles som en rigtig lille app og ikke bare et skoleeksempel.
Typiske fejl og hvordan du faktisk finder dem
Her er der, hvor mange tutorials stopper. De viser den pæne løsning, men ikke den rodede virkelighed, hvor intet virker første gang.
1. “Cannot read property ‘value’ of null”
Det betyder næsten altid at din document.getElementById ikke fandt noget.
- Tjek at id i HTML og JS matcher 100 % (
todo-inputvstodo_input) - Tjek at dit script er inkluderet efter HTML’en, eller at du bruger
defer
2. “todos is not defined”
Typisk årsag: du har skrevet let todos inde i en funktion, men bruger den udenfor.
Løsning: deklarér let todos = []; øverst i din fil, udenfor funktioner, så både render(), toggleTodo() osv. kan se den.
3. localStorage gemmer ikke noget
>Tjekliste:- Åbn DevTools → Application → Local Storage
- Klik på dit domæne
- Se om der kommer en nøgle med navnet
my_todo_app_v1når du tilføjer en opgave
Hvis ikke:
- Er
saveTodos()kaldt alle steder, hvor du ændrertodos? - Er
STORAGE_KEYstavet ens begge steder?
4. Opgaverne forsvinder ved reload
Det her er næsten altid fordi du glemmer at kalde loadTodos() og render() ved start.
Sørg for du har:
loadTodos();
render();
enten i bunden af din JS-fil eller inde i en DOMContentLoaded-listener:
document.addEventListener('DOMContentLoaded', function () {
loadTodos();
render();
});
5. Klik på checkbox rammer ikke det rigtige todo
Hvis toggling eller delete rammer de forkerte opgaver, så er det næsten altid fordi id’et ikke matches korrekt.
>Tjek i DevTools:- Højreklik på en opgave → Inspect
- Tjek at
<li>hardata-id="..." - Log id’et i
toggleTodoellerdeleteTodomedconsole.log(id, todos)
Hvis der står undefined, så er det din dataset.id der ikke er sat rigtigt i render().
Små udvidelser når grundkoden virker
Når du har en stabil base, er det nu det bliver sjovt. Her er nogle oplagte ting at bygge ovenpå.
Filter: alle, aktive, færdige
Tilføj tre knapper:
<div class="filters">
<button data-filter="all">Alle</button>
<button data-filter="active">Aktive</button>
<button data-filter="done">Færdige</button>
</div>
Og en lille ekstra state-variabel:
let currentFilter = 'all';
Opdater render() til at filtrere:
function getVisibleTodos() {
if (currentFilter === 'active') {
return todos.filter(t => !t.done);
}
if (currentFilter === 'done') {
return todos.filter(t => t.done);
}
return todos;
}
function render() {
listEl.innerHTML = '';
const visibleTodos = getVisibleTodos();
visibleTodos.forEach(todo => {
// samme som før
});
}
Og så en event listener på filter-knapperne:
const filtersEl = document.querySelector('.filters');
filtersEl.addEventListener('click', function (event) {
const btn = event.target.closest('button[data-filter]');
if (!btn) return;
currentFilter = btn.dataset.filter;
render();
});
Tastaturgenveje: Enter og Escape
Enter håndterer vi allerede via formen. Men du kan også tilføje fx Escape til at rydde inputfeltet.
inputEl.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
inputEl.value = '';
}
});
Hvis du vil lege mere med tastatur, kan du fx kigge på hvordan man styrer fokus og shortcuts i små web apps. Vi har en artikel om JavaScript events og tastatur, som går mere ned i det.
Hvor du kan tage det videre fra her
Hvis du har fulgt med hertil, har du faktisk bygget et rigtigt lille JavaScript projekt for begyndere, som rammer mange af de vigtigste ting: DOM manipulation, event listeners, state og localStorage.
Et naturligt næste skridt er at kigge på:
- at splitte koden op i flere filer
- at skrive små hjælpetests for dine funktioner (fx
toggleTodo) - at bygge en variant i et framework som React og sammenligne tankegangen
Hvis du vil styrke dine basis-JavaScript skills, er det også værd at kigge på nogle af de andre artikler om begyndervenlige projekter på Coding Class, fx en simpel JavaScript stopur eller små DOM-øvelser.
Og ja, du må gerne omskrive hele din to do app tre gange. Det gjorde jeg også.
- og find target med e.target.closest('.todo-item') eller en knap-klasse. Det undgår at skulle fjerne/tilføje lyttere når du rerender hele listen.







Send kommentar
Du skal være logget ind for at skrive en kommentar.