Jeg lærte JavaScript ved at skrive en to do app igen og igen

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 opgaven
  • id="todo-input" og id="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:

  1. Rydder <ul id="todo-list">
  2. Looper over todos
  3. 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.

Typisk fejl: Du prøver at opdatere både DOM og 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.

Typisk fejl: Du glemmer at opdatere 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 i localStorage
  • Når vi vil læse: hent strengen, JSON.parse den 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.

Typisk fejl: Du glemmer 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-input vs todo_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_v1 når du tilføjer en opgave

Hvis ikke:

  • Er saveTodos() kaldt alle steder, hvor du ændrer todos?
  • Er STORAGE_KEY stavet 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:

  1. Højreklik på en opgave → Inspect
  2. Tjek at <li> har data-id="..."
  3. Log id’et i toggleTodo eller deleteTodo med console.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å.

I moderne browsere kan du bruge crypto.randomUUID() for korte, kollisionsfri id'er. Hvis du vil have fallback til ældre browsere, kan du kombinere tidspunkt og tilfældighed, f.eks. Date.now().toString(36) + Math.random().toString(36).slice(2,8). Gem altid id som string.
På load prøver du JSON.parse(localStorage.getItem('todos')) i et try/catch og tjekker, at det er et array før du sætter din state, ellers fallback til []. Efter hver ændring laver du localStorage.setItem('todos', JSON.stringify(todos')). Overvej at inkludere et versionsfelt hvis datamodellen kan ændre sig.
Sæt brugerens tekst ind med textContent eller createTextNode i stedet for innerHTML, fx li.textContent = todo.text. Hvis du absolut skal bruge HTML, så escape brugerinput først eller brug en sanitiseringsbibliotek.
Event delegation er ofte simplere og mere effektiv: sæt én click-listener på
    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.

Lasse Falkenberg er typen, der begyndte at rode med HTML og CSS for at lave en simpel bandside – og opdagede, at det var langt sjovere at få knapperne til at virke end at stå på scenen. Siden har han kastet sig over alt fra små JavaScript-snippets til Python-scripts, der kan spare ham for kedeligt, manuelt arbejde i hverdagen.

Han har lært det meste ved at bygge ting, der lige præcis løser hans egne problemer: en lille webapp til at holde styr på brætspilsaftener, et script til at rydde op i rodede mapper, eller en enkel side til at dele noter med venner. Undervejs har han kæmpet sig gennem alle de klassiske fejl – semikolon, forkerte indrykninger og variabler, der hedder noget helt andet end man tror – og det er præcis den rejse, han deler på Coding Class.

På Coding Class skriver Lasse praktiske, jordnære guides, der tager udgangspunkt i små, konkrete opgaver: noget du kan se, teste og bygge videre på med det samme. Han elsker at bryde en opgave ned i små bidder, vise den fulde kode og forklare linje for linje, hvad der sker – inklusive de typiske bugs, du med stor sandsynlighed også støder på.

For Lasse handler kodning ikke om flotte titler eller store ord, men om følelsen af at få noget til at virke – og om at du som læser kan gå derfra med noget, du selv har bygget. Hvis du kan kende glæden ved at få en fejl til endelig at forsvinde, er du lige på bølgelængde med hans måde at lære fra sig på.

Send kommentar

You May Have Missed