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
remainingCountseparat fratodos - 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:
- Init: Ved start læser du én gang fra
localStorageog sætter din in-memory state - Runtime: Resten af tiden arbejder du KUN med in-memory state
- 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
localStorageinde 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
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.







1 kommentar