Den dag min to-do liste begyndte at lyve for mig

Den dag min to-do liste begyndte at lyve for mig

En gennemsnitlig frontend-app har typisk 3-7 steder, hvor state bliver ændret uden nogen samlet plan.

Da min to-do liste gik i stykker uden fejl i konsollen

Jeg kan ret præcist huske den aften, hvor jeg første gang tænkte: “Okay, nu styrer koden mig, ikke omvendt.”

Jeg sad med en helt klassisk to-do app. Ingen framework-magi, bare HTML, lidt CSS og en smule JavaScript. Det startede pænt: én input-boks, én liste, én knap. Alt var godt.

Så ville jeg “bare lige” tilføje:

  • Filter: vis alle / kun færdige / kun ufærdige
  • En counter med “3 opgaver tilbage”
  • Gem i localStorage, så tingene ikke forsvandt ved reload

Pludselig skete der mærkelige ting:

  • Jeg slettede en opgave, men counteren opdaterede ikke
  • Filteret viste noget andet end det, der faktisk var i DOM’en
  • Nogle gange kom gamle opgaver tilbage efter reload

Ingen fejl i konsollen. Ingen røde streger. Appen “virkede” teknisk set. Men den fortalte ikke sandheden om sine egne data. Min to-do liste løj.

Fejlen var ikke én bestemt linje kode. Fejlen var, at jeg ingen strategi havde for state. Jeg satte bare variabler og DOM direkte, hvor det lige passede.

Symptomerne: sådan føles dårlig state management

Hvis du kan genkende noget af det her, er du ikke alene:

  • Du er bange for at slette kode, fordi “det der over hjørnet” måske bruger den
  • Du er nødt til at klikke rundt i din UI for at forstå, hvad der foregår
  • Du retter en lille ting ét sted, og noget helt andet går i stykker
  • Der er flere sandheder: én i DOM’en, én i nogle variabler, én i localStorage

Det er typisk sket sådan her:

  • Du startede med lokal state i én funktion eller én fil
  • Så skulle en anden del af appen bruge den samme information
  • Du kopierede måske noget data, eller gemte det et nyt sted “bare lige for nu”
  • Efter et par features mere er der gået spaghetti i det

Det er helt normalt. Ingen starter med en arkitekturdiagram, når de bygger deres første to-do app. Man skriver kode, indtil den gør det rigtige. Og så en dag gør den pludselig noget andet.

Tre niveauer af state i små apps

Før vi begynder at rydde op, er det nyttigt at skelne mellem tre slags state. Du behøver ikke bruge fancy ord i din kode, men du skal kende forskellen i hovedet.

Lokal state: ting, kun én komponent eller funktion bruger

Det er den lille, ufarlige slags state. For eksempel:

  • Værdien i et input-felt, mens du skriver
  • Om en dropdown er åben eller lukket
  • Hvilken faneblad der er aktivt i et lille UI-område

Her giver det mening at holde state tæt på det DOM-element, der bruger det.

Delt state: ting, flere steder i UI’et skal være enige om

Det er her, det begynder at gøre ondt, hvis du ikke tænker dig om.

Eksempler:

  • Listen af to-dos (bruges både til liste, counter og filter)
  • Hvilken bruger der er logget ind
  • Aktuelt valgte produkt i en shop, der både vises i header, sidebar og main

Delt state kræver en form for single source of truth: ét sted der bestemmer sandheden. Alt andet skal være afledt.

Persistet state: ting, der skal overleve et refresh

Til sidst har du det, der skal gemmes:

  • Brugerens indstillinger (tema, sprog, filter)
  • Data fra et API, du cacher i localStorage
  • To-dos, så de er der igen i morgen

Persistens er ikke bare “smid det i localStorage“. Det er en synkroniseringsopgave mellem:

  • State i hukommelsen (JavaScript-variabler)
  • State i storage (localStorage, backend, database, hvad som helst)

En god tommelfingerregel: din app arbejder altid med in-memory state først. Persistens er en ekstra ting ovenpå.

Pattern 1: Ét state-objekt + én render-funktion

Det første lille, men vigtige skridt væk fra spaghetti er: stop med at have 17 løse variabler, og saml tingene.

Fra spredte variabler til ét objekt

Forestil dig, at du har noget i den her stil:

let todos = []
let filter = 'all' // 'all' | 'active' | 'completed'
let remainingCount = 0

function addTodo(text) {
  todos.push({ id: Date.now(), text, completed: false })
  remainingCount++
  renderTodos()
  renderCount()
}

Her er der allerede to problemer:

  • Du holder remainingCount separat fra todos
  • Du har flere render-funktioner, der hver især skal huske at blive kaldt

Første skridt: ét state-objekt.

const state = {
  todos: [],
  filter: 'all'
}

Alt hvad din app ved om sig selv, ligger her. Ingen ekstra tæller. Ingen ekstra flag, medmindre det faktisk hører til state.

Én render-funktion, der afspejler state

Næste skridt er at have én central funktion, der ved, hvordan hele UI’et skal se ud givet den nuværende state.

function render() {
  renderTodos()
  renderCount()
  renderFilterButtons()
}

function renderTodos() {
  const list = document.querySelector('#todo-list')
  list.innerHTML = ''

  const visibleTodos = state.todos.filter(todo => {
    if (state.filter === 'active') return !todo.completed
    if (state.filter === 'completed') return todo.completed
    return true
  })

  visibleTodos.forEach(todo => {
    const li = document.createElement('li')
    li.textContent = todo.text
    if (todo.completed) li.classList.add('completed')
    list.appendChild(li)
  })
}

function renderCount() {
  const count = state.todos.filter(t => !t.completed).length
  document.querySelector('#count').textContent = `${count} tilbage`
}

Læg mærke til noget vigtigt:

  • Ingen steder gemmer vi længere remainingCount
  • Count er afledt af state.todos, hver gang vi renderer

Sådan undgår du, at ting kommer ud af sync. Du beregner dem hver gang i stedet for at forsøge at vedligeholde flere sandheder.

En lille updater-funktion som indgang

Til sidst er det rart at have ét sted, hvor state må ændres.

function setState(partial) {
  Object.assign(state, partial)
  render()
}

function addTodo(text) {
  const newTodo = { id: Date.now(), text, completed: false }
  setState({ todos: [...state.todos, newTodo] })
}

function setFilter(filter) {
  setState({ filter })
}

Nu sker der to ting automatisk, hver gang state ændres:

  • State bliver opdateret ét sted
  • Hele UI’et bliver re-renderet

Det her pattern lyder simpelt (for det er det), men det er faktisk en mini-version af, hvad mange frameworks gør bag kulisserne.

Hvis du vil se et beslægtet mønster omsat til React, har jeg en artikel om frontend struktur og state, hvor idéen går igen, bare med hooks.

Pattern 2: En lille event-bus til løs kobling

Det næste problem opstår, når din app får flere “dele”, der ikke skal kende hinanden direkte, men stadig reagere på de samme ting.

Eksempel: Når en to-do oprettes, skal:

  • Listen opdateres
  • Counteren opdateres
  • State gemmes i localStorage

Du kan selvfølgelig bare kalde tre forskellige funktioner inde i addTodo. Men så bliver alt afhængigt af alt.

En minimal pub-sub uden magi

Pub-sub (publish-subscribe) lyder fancy, men det kan bare være 10 linjer kode:

const events = {}

function on(eventName, handler) {
  if (!events[eventName]) events[eventName] = []
  events[eventName].push(handler)
}

function emit(eventName, payload) {
  (events[eventName] || []).forEach(handler => handler(payload))
}

Nu kan du gøre sådan her:

on('todo:added', () => {
  render()
})

on('todo:added', () => {
  saveToLocalStorage()
})

function addTodo(text) {
  const newTodo = { id: Date.now(), text, completed: false }
  state.todos.push(newTodo)
  emit('todo:added', newTodo)
}

Pointen er ikke at lave et generelt bibliotek. Pointen er at få en form for signal i koden:

  • Når noget ændrer state, udsender det en begivenhed
  • Alt, der skal reagere, kan lytte uden at kende funktionen direkte

Det kan virke overkill i en lille app, men allerede når du har 3-4 ting, der skal reagere på den samme handling, bliver det rart.

Hvornår er en event-bus nok?

Jeg bruger den her slags i små projekter, hvor:

  • Der ikke er noget stort framework
  • Jeg har et par “features”, der skal reagere på de samme events
  • Jeg vil undgå at én fil importerer halvdelen af projektet

Men der er også en grænse. Hvis du opdager, at du har 15 events og ingen aner, hvad der sker, når noget emitter, så er du nået dertil, hvor du skal op i næste pattern.

Pattern 3: En simpel store med subscribe

Hvis du har kigget på Redux eller andre state biblioteker og tænkt “det her er for meget til min lille app”, så er jeg helt enig.

Men du kan stjæle den centrale idé uden alt det tunge: en store, der:

  • Har én intern state
  • Har en getState() funktion
  • Har en setState() funktion
  • Lader andre subscribe til ændringer

En minimal store i 20 linjer

function createStore(initialState) {
  let state = initialState
  const listeners = new Set()

  function getState() {
    return state
  }

  function setState(partial) {
    state = { ...state, ...partial }
    listeners.forEach(fn => fn(state))
  }

  function subscribe(fn) {
    listeners.add(fn)
    return () => listeners.delete(fn)
  }

  return { getState, setState, subscribe }
}

const store = createStore({
  todos: [],
  filter: 'all'
})

Nu kan forskellige dele af din app koble sig på state-ændringer:

store.subscribe(state => {
  renderTodos(state)
})

store.subscribe(state => {
  renderCount(state)
})

store.subscribe(state => {
  saveToLocalStorage(state)
})

Og dine “actions” bliver bare funktioner, der bruger setState:

function addTodo(text) {
  const current = store.getState()
  const newTodo = { id: Date.now(), text, completed: false }
  store.setState({ todos: [...current.todos, newTodo] })
}

function toggleTodo(id) {
  const current = store.getState()
  const todos = current.todos.map(todo =>
    todo.id === id ? { ...todo, completed: !todo.completed } : todo
  )
  store.setState({ todos })
}

Bemærk: stadig ingen Redux, ingen actions-typer, ingen reducers. Bare et mønster.

Hvornår giver en store mening?

Jeg plejer at gå efter noget i den her stil:

  • Der er mere end 1-2 forskellige UI-dele, der afhænger af den samme state
  • State skal både vises, gemmes og måske sendes til et API
  • Du har mere end én side/”view”, men vil genbruge state på tværs

Hvis du er helt ny, kan du starte med pattern 1 (ét state-objekt + render) og først bygge videre til en store, når du kan mærke, at der er for mange ting, der skal reagere samtidigt.

Hvis du senere hopper over i et framework, vil mange af pointerne her føles genkendelige. Fx i React med context, eller når du kigger på state libraries. På Coding Class har vi flere introduktioner til netop React-state, men det er rart at kunne principperne uden framework først.

Persistens uden at skyde dig selv i foden

Tilbage til to-do appen fra starten. Jeg havde en bug, hvor gamle opgaver nogle gange kom tilbage efter refresh, selvom jeg var sikker på, at jeg slettede dem.

Problemet var, at jeg blandede state og localStorage på mærkelige tidspunkter.

Grundreglen: ét sted læser du fra storage, ét sted skriver du

En simpel strategi:

  1. Init: Ved start læser du én gang fra localStorage og sætter din in-memory state
  2. Runtime: Resten af tiden arbejder du KUN med in-memory state
  3. Sync: Når state ændrer sig, skriver du den nye state til storage

Med vores lille store kan det se sådan her ud:

const STORAGE_KEY = 'todos-app-state'

function loadInitialState() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    if (!raw) return { todos: [], filter: 'all' }
    const parsed = JSON.parse(raw)
    // Simpel migration med defaults
    return {
      todos: parsed.todos || [],
      filter: parsed.filter || 'all'
    }
  } catch (e) {
    console.error('Kunne ikke læse state fra localStorage', e)
    return { todos: [], filter: 'all' }
  }
}

const store = createStore(loadInitialState())

store.subscribe(state => {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
  } catch (e) {
    console.error('Kunne ikke gemme state', e)
  }
})

Nu sker alt state-arbejde gennem store.getState() og store.setState(). Ingen andre steder i koden må kalde localStorage.setItem direkte.

Typiske localStorage-fejl

Nogle klassikere, jeg selv har lavet flere gange:

  • Du læser fra localStorage inde i en render-funktion (så state hopper frem og tilbage)
  • Du gemmer kun dele af state, ikke hele objektet (og mister resten)
  • Du ændrer shape på din state, men håndterer ikke gamle data

Det sidste kan du afbøde med små migrations-regler, som i eksemplet ovenfor: sørg for defaults, hvis noget mangler.

Hvis du senere går videre til backend og databaser, er principperne egentlig de samme. Vi har en artikel om SQL basics, hvor mønstrene omkring data og sandhed også går igen, bare server-side.

Anti-patterns: det der føles hurtigt, men bliver dyrt

Når du bygger små projekter, er det fristende bare at “gøre det der virker nu”. Det er fair. Men nogle mønstre gør det unødvendigt svært for dig senere.

Direkte DOM-manipulation overalt

Hvis du flere steder i din kode gør sådan noget her:

document.querySelector('#count').textContent = '3 tilbage'

// ... 50 linjer senere

document.querySelector('#count').textContent = '2 tilbage'

Så har du ikke længere et klart svar på spørgsmålet: “Hvad er sandheden om count?”

Bedre: beregn altid UI ud fra state. Og opdater DOM ét (eller få) centrale steder.

Global mutable state uden struktur

Global state i sig selv er ikke giftigt. Problemet er global state uden mønster.

window.todos = []

function someFeature() {
  window.todos.push(...)
  // gør et eller andet mere
}

function anotherFeature() {
  window.todos = window.todos.filter(...)
  // gør noget andet
}

Hvis du vil have global state, så pak det i noget, der ligner en store eller i det mindste et objekt med klare metoder:

const Todos = {
  items: [],
  add(text) { /* ... */ },
  toggle(id) { /* ... */ }
}

Så kan du altid søge efter Todos. i din editor og se alle steder, der rører ved den globale state.

Afledt state som “rigtig” state

Det her er en klassiker: du gemmer ting, du egentlig kunne beregne.

const state = {
  todos: [],
  remainingCount: 0
}

Hver gang du gør det, påtager du dig en ekstra forpligtelse: alle steder, der ændrer todos, skal også huske at opdatere remainingCount. Det glemmer man før eller siden.

Hvis du kan beregne noget på under et millisekund, så gør det i render-funktionen i stedet.

En lille refaktor-rejse i fire trin

Lad os tage en meget typisk lille app-struktur og rydde den op uden at skifte til et framework.

Udgangspunkt: “det virkede jo”-versionen

let todos = []
let filter = 'all'

const listEl = document.querySelector('#todo-list')
const inputEl = document.querySelector('#todo-input')
const countEl = document.querySelector('#count')

function addTodo() {
  const text = inputEl.value.trim()
  if (!text) return

  const todo = { id: Date.now(), text, completed: false }
  todos.push(todo)

  const li = document.createElement('li')
  li.textContent = todo.text
  listEl.appendChild(li)

  countEl.textContent = todos.length + ' tilbage'
}

document
  .querySelector('#add-btn')
  .addEventListener('click', addTodo)

Det her er faktisk fint for en helt lille øvelse. Men så snart du vil have filter, toggle, slet, gem osv., begynder problemerne.

Trin 1: Saml state i ét objekt

const state = {
  todos: [],
  filter: 'all'
}

Ret alle steder, så de bruger state.todos i stedet for todos. Ingen logik ændret endnu.

Trin 2: Indfør én render-funktion

function render() {
  listEl.innerHTML = ''

  const visible = state.todos.filter(todo => {
    if (state.filter === 'active') return !todo.completed
    if (state.filter === 'completed') return todo.completed
    return true
  })

  visible.forEach(todo => {
    const li = document.createElement('li')
    li.textContent = todo.text
    listEl.appendChild(li)
  })

  const remaining = state.todos.filter(t => !t.completed).length
  countEl.textContent = remaining + ' tilbage'
}

function addTodo() {
  const text = inputEl.value.trim()
  if (!text) return

  const todo = { id: Date.now(), text, completed: false }
  state.todos.push(todo)
  render()
}

Nu skriver vi ikke direkte til DOM andre steder end i render. Det er et kæmpe skridt i sig selv.

Trin 3: Indfør en mini-store

Byg en lille createStore som tidligere og flyt state derind. Din render-funktion bliver en subscriber:

const store = createStore({
  todos: [],
  filter: 'all'
})

store.subscribe(render)

function render(state) {
  // samme som før, men bruger state-argument
}

function addTodo() {
  const text = inputEl.value.trim()
  if (!text) return

  const todo = { id: Date.now(), text, completed: false }
  const current = store.getState()
  store.setState({ todos: [...current.todos, todo] })
}

Nu har du et klart mønster: UI’et reagerer på state, actions ændrer state.

Trin 4: Tilføj persistens som et ekstra lag

Til sidst kobler du localStorage på som endnu en subscriber, ikke som noget, der gemmer data hist og pist.

store.subscribe(state => {
  localStorage.setItem('todos-app-state', JSON.stringify(state))
})

Hvis du på et tidspunkt vil skifte til backend-kald i stedet for localStorage, kan du nøjes med at ændre den ene subscriber.

Hvornår skal du begynde at tænke på state management?

Hvis du er helt i starten, vil jeg faktisk anbefale, at du starter med stores og event-busser. Byg nogle små ting, lav fejl, oplev spaghetti.

Når du så rammer det punkt, hvor din app føles uærlig, fordi forskellige dele siger forskellige ting, er det et tegn på, at du er klar til et af patterns’ne her.

Og næste gang du sidder med en lille app, der er ved at vokse ud af “bare variabler og DOM”, så prøv at indføre bare ét skridt: saml al state i ét objekt og lav en render-funktion, der altid afspejler det. Det er overraskende, hvor langt man kommer med bare det.

Find alle steder der skriver eller læser state, og flyt dem til et enkelt modul eller objekt med et lille API (fx get, set, subscribe). Ændr render-funktionen, så DOM altid er en afledning af den centrale state, og stop med at opdatere DOM direkte andre steder. Refaktor trinvis: ret én funktion ad gangen og test efter hver ændring.
Vælg et bibliotek når delt state er spredt over mange komponenter, asynkrone flows bliver komplekse, eller flere udviklere skal arbejde konsistent med state; ellers er et lille centralt modul eller simpel pubsub ofte nok. Start simpelt og indfør et bibliotek først hvis kompleksiteten eller boilerplate-behovet vokser.
Skriv altid til localStorage fra ét sted i din app og serialiser state konsekvent, så du ikke får inkonsistente former. Når du læser data ved opstart, valider og migrer schema-versioner eller fall tilbage til default hvis noget ser forkert ud. Overvej at debounce opslag til localStorage for at undgå race conditions ved hurtige ændringer.
Søg i koden efter alle steder der manipulerer DOM, ændrer variabler eller skriver til localStorage, og noter dem som mistænkte. Instrumenter central state med logning eller snapshots før/efter mutationer, brug enkle assertions der sammenligner store vs DOM efter brugerhandlinger, og refaktor derefter de fundne skrivepunkter til ét update-sted.

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å.

1 kommentar

comments user
Torben

min gamle bus-app, altså hjemmebygget to-do til vagter, begyndte at lyve om opgaver, blev helt forvirret 😂

Send kommentar

You May Have Missed